humanish 0.129.0 → 0.131.0

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 (53) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/actors/codex/restricted-participant.d.ts +2 -2
  3. package/dist/actors/codex/restricted-participant.js +3 -3
  4. package/dist/actors/computer-use/loop/actions.d.ts +17 -2
  5. package/dist/actors/computer-use/loop/actions.js +24 -5
  6. package/dist/actors/computer-use/loop/backstop.d.ts +9 -1
  7. package/dist/actors/computer-use/loop/backstop.js +4 -3
  8. package/dist/actors/computer-use/loop/clock.d.ts +4 -4
  9. package/dist/actors/computer-use/loop/clock.js +5 -5
  10. package/dist/actors/computer-use/loop/observation.d.ts +21 -2
  11. package/dist/actors/computer-use/loop/observation.js +50 -8
  12. package/dist/actors/computer-use/loop/trace.js +5 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +30 -19
  14. package/dist/actors/computer-use/loop/watch.d.ts +42 -0
  15. package/dist/actors/computer-use/loop/watch.js +260 -0
  16. package/dist/actors/computer-use/loop.js +18 -4
  17. package/dist/actors/computer-use/wait.d.ts +36 -18
  18. package/dist/actors/computer-use/wait.js +51 -22
  19. package/dist/actors/contract.d.ts +34 -0
  20. package/dist/actors/local-agent/cli.js +5 -0
  21. package/dist/actors/stop-cause.d.ts +1 -1
  22. package/dist/analysis/automatic.js +0 -1
  23. package/dist/analysis/evidence-sources.d.ts +44 -7
  24. package/dist/analysis/evidence-sources.js +40 -6
  25. package/dist/analysis/evidence.js +96 -122
  26. package/dist/analysis/service.js +12 -6
  27. package/dist/analysis/source.d.ts +28 -0
  28. package/dist/analysis/source.js +118 -0
  29. package/dist/evidence/frame-change.d.ts +39 -0
  30. package/dist/evidence/frame-change.js +293 -0
  31. package/dist/feedback/export.js +0 -1
  32. package/dist/observer/data.d.ts +2 -2
  33. package/dist/observer-app.html +1 -1
  34. package/dist/run/bundle-shape.d.ts +2 -2
  35. package/dist/run/bundle.d.ts +8 -1
  36. package/dist/run/detail.js +4 -1
  37. package/dist/run/participant-records.d.ts +1 -1
  38. package/dist/run/stream-shape.d.ts +2 -2
  39. package/dist/run/stream-trace.d.ts +21 -7
  40. package/dist/run/stream-trace.js +42 -8
  41. package/dist/run/streams.d.ts +18 -0
  42. package/dist/study/bundle-estimate.d.ts +4 -1
  43. package/dist/study/bundle-estimate.js +40 -15
  44. package/dist/study/plan-participants.d.ts +1 -1
  45. package/dist/study/types.d.ts +11 -7
  46. package/dist/study/warnings.js +1 -1
  47. package/dist/verify/verify.js +28 -21
  48. package/docs/architecture/actor-contract.md +8 -0
  49. package/docs/architecture/browser-control.md +11 -9
  50. package/docs/contracts/run-bundle.md +2 -2
  51. package/docs/contracts/schemas.md +48 -8
  52. package/docs/contracts/study-analysis.md +13 -1
  53. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ // How analysis reads a run's recorded source: run.json, then each participant's trace one at a time,
2
+ // and the refusals it gives for either.
3
+ import { isCommsReceivingEvidence } from "../comms/receiving-evidence.js";
4
+ import { isReadRunBundleSchema } from "../run/bundle.js";
5
+ import { runIdOf } from "../run/paths.js";
6
+ import { reduceRunTraces, StreamTraceRefusedError, StreamTraceTooLargeError, } from "../run/stream-trace.js";
7
+ import { isRecord } from "../run/type-guards.js";
8
+ import { EVIDENCE_LIMITS } from "./analysis-limits.js";
9
+ /**
10
+ * A refusal of the run's recorded source. Its message is the code, as every analysis error's is,
11
+ * and `reason` says which file analysis did not read and why, for the person who asked.
12
+ */
13
+ export class AnalysisSourceRefusedError extends Error {
14
+ reason;
15
+ constructor(code, reason) {
16
+ super(code);
17
+ this.reason = reason;
18
+ this.name = "AnalysisSourceRefusedError";
19
+ }
20
+ }
21
+ const bytes = (count) => `${count.toLocaleString("en-US")} bytes`;
22
+ const limit = (max) => `${bytes(max)} (${(max / (1024 * 1024)).toLocaleString("en-US", { maximumFractionDigits: 1 })} MiB)`;
23
+ const runFileInvalid = (reason) => new AnalysisSourceRefusedError("ANALYSIS_SOURCE_INVALID", `Analysis did not read run.json: ${reason}`);
24
+ const traceInvalid = (streamId, reason) => new AnalysisSourceRefusedError("ANALYSIS_SOURCE_INVALID", `Analysis did not read participant ${streamId}'s trace: ${reason}`);
25
+ /** The refusal of a run.json of `size` bytes, more than the `max` analysis reads of it. */
26
+ export function runFileTooLarge(size, max) {
27
+ return new AnalysisSourceRefusedError("ANALYSIS_SOURCE_TOO_LARGE", `Analysis did not read run.json: it holds ${bytes(size)}, more than the ${limit(max)} analysis reads of it. run.json grows with each participant and the study's mission; split the participants across studies to keep it under the limit.`);
28
+ }
29
+ /**
30
+ * run.json's bundle, when it is a run bundle of the prepared run that analysis reads: at most
31
+ * EVIDENCE_LIMITS.sourceBytes, at most EVIDENCE_LIMITS.participants participants, and each
32
+ * participant and event with the fields analysis reads.
33
+ */
34
+ export function parseAnalysisSource(prepared, bytes) {
35
+ if (bytes.length > EVIDENCE_LIMITS.sourceBytes)
36
+ throw runFileTooLarge(bytes.length, EVIDENCE_LIMITS.sourceBytes);
37
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
38
+ if (!isRecord(value) || !isReadRunBundleSchema(value.schema))
39
+ throw runFileInvalid("it is not a run bundle of a schema this version of humanish reads.");
40
+ if (value.runId !== runIdOf(prepared))
41
+ throw runFileInvalid(`it records another run than ${runIdOf(prepared)}, the run in its folder.`);
42
+ if (!Array.isArray(value.streams) || !Array.isArray(value.events))
43
+ throw runFileInvalid("it has no participants or events list.");
44
+ if (value.streams.length > EVIDENCE_LIMITS.participants)
45
+ throw runFileInvalid(`it lists ${value.streams.length} participants, more than the ${EVIDENCE_LIMITS.participants} analysis reads; split the participants across studies.`);
46
+ if (value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving))
47
+ throw runFileInvalid("its email receiving evidence holds a field or value analysis does not read.");
48
+ const ids = new Set();
49
+ for (const [index, stream] of value.streams.entries()) {
50
+ if (!isRecord(stream) ||
51
+ typeof stream.id !== "string" ||
52
+ stream.id.length === 0 ||
53
+ stream.id.length > 256 ||
54
+ ids.has(stream.id) ||
55
+ typeof stream.label !== "string" ||
56
+ typeof stream.status !== "string") {
57
+ throw runFileInvalid(`participant ${index + 1} needs an id of 1 to 256 characters no other participant has, a label and a status.`);
58
+ }
59
+ ids.add(stream.id);
60
+ if (stream.actor !== undefined && !isRecord(stream.actor))
61
+ throw runFileInvalid(`participant ${stream.id}'s actor is not an object.`);
62
+ }
63
+ for (const [index, event] of value.events.entries()) {
64
+ if (!isRecord(event) ||
65
+ typeof event.id !== "string" ||
66
+ typeof event.message !== "string" ||
67
+ typeof event.type !== "string")
68
+ throw runFileInvalid(`event ${index + 1} needs an id, a type and a message.`);
69
+ }
70
+ return value;
71
+ }
72
+ /**
73
+ * What `keep` takes from each stream of run.json's bundle with the stream's trace loaded, in stream
74
+ * order. Each trace file is read up to RUN_ARTIFACT_MAX_BYTES, as the loader, verify and the
75
+ * Observer read it, and is dropped once `keep` returns, so analysis holds one trace at a time
76
+ * whatever the number of participants. run.json's digest, which the analysis record binds, binds
77
+ * each trace's sha256. A trace whose index records more than the limit is refused as
78
+ * ANALYSIS_SOURCE_TOO_LARGE, and any other trace the loader refuses as ANALYSIS_SOURCE_INVALID.
79
+ */
80
+ export async function readParticipantTraces(prepared, bundle, keep) {
81
+ try {
82
+ return await reduceRunTraces(prepared, bundle, (stream, index) => {
83
+ validateTraceItems(stream);
84
+ return keep(stream, index);
85
+ });
86
+ }
87
+ catch (error) {
88
+ // The loader's refusal names the participant, the file, why, and what to do.
89
+ if (error instanceof StreamTraceRefusedError)
90
+ throw new AnalysisSourceRefusedError(error instanceof StreamTraceTooLargeError
91
+ ? "ANALYSIS_SOURCE_TOO_LARGE"
92
+ : "ANALYSIS_SOURCE_INVALID", error.message);
93
+ throw error;
94
+ }
95
+ }
96
+ function validateTraceItems(stream) {
97
+ const actor = stream.actor;
98
+ if (actor === undefined)
99
+ return;
100
+ if (!isRecord(actor) || !Array.isArray(actor.items))
101
+ throw traceInvalid(stream.id, "it has no items list.");
102
+ // The place each id was first seen. A refusal names places, since an id is trace text.
103
+ const firstPlace = new Map();
104
+ for (const [index, item] of actor.items.entries()) {
105
+ if (!isRecord(item) ||
106
+ typeof item.id !== "string" ||
107
+ item.id.length === 0 ||
108
+ item.id.length > 256 ||
109
+ typeof item.kind !== "string" ||
110
+ typeof item.title !== "string" ||
111
+ (item.text !== undefined && typeof item.text !== "string"))
112
+ throw traceInvalid(stream.id, `item ${index + 1} needs an id of 1 to 256 characters, a kind and a title, and a text that is a string if it has one.`);
113
+ const first = firstPlace.get(item.id);
114
+ if (first !== undefined)
115
+ throw traceInvalid(stream.id, `items ${first + 1} and ${index + 1} share an id, and analysis cites each item by its id.`);
116
+ firstPlace.set(item.id, index);
117
+ }
118
+ }
@@ -0,0 +1,39 @@
1
+ /** A rectangle of the frame, in pixels from its top-left corner. */
2
+ export interface FrameRegion {
3
+ readonly x: number;
4
+ readonly y: number;
5
+ readonly width: number;
6
+ readonly height: number;
7
+ }
8
+ /**
9
+ * What one look found, compared with the frame the participant last saw. `settled`: a region of
10
+ * change held still long enough to wake the participant; `area` bounds it, and `small` is true
11
+ * when it is at most LARGE_REGION_TILES tiles, which the participant's hint names. `changing`: tiles moved
12
+ * since the previous look and nothing settled; `lookSoon` when more than LARGE_REGION_TILES moved,
13
+ * so a large change is confirmed by a quick look. `unchanged`: nothing moved and nothing settled.
14
+ * `held` on the last two bounds a small region that held still past the hold and is held back by a
15
+ * ticking tile or a large moving region near it.
16
+ */
17
+ export type FrameVerdict = {
18
+ readonly verdict: "unchanged";
19
+ readonly held?: FrameRegion;
20
+ } | {
21
+ readonly verdict: "changing";
22
+ readonly lookSoon: boolean;
23
+ readonly held?: FrameRegion;
24
+ } | {
25
+ readonly verdict: "settled";
26
+ readonly area: FrameRegion;
27
+ readonly small: boolean;
28
+ };
29
+ /**
30
+ * Compare later looks at one screen with `reference`, the frame the participant last saw. Call
31
+ * `next` once per look, in order, with the look's time. Each tile that differs from the reference
32
+ * keeps the time it last moved, so a moving tile (a spinner) never joins a still region beside
33
+ * it. Nothing is masked: a large change that stops changing always settles, and a small one does
34
+ * unless a tile near it keeps ticking. Stillness is sampled at looks, so a blink seen in the same
35
+ * phase at every look reads as still.
36
+ */
37
+ export declare function watchFrames(reference: Buffer): {
38
+ next(frame: Buffer, atMs: number): FrameVerdict;
39
+ };
@@ -0,0 +1,293 @@
1
+ import { PNG } from "pngjs";
2
+ // Whether a screen changed while a participant waited, and whether the change has stopped
3
+ // changing. The loop's watch (src/actors/computer-use/loop/watch.ts) asks after each look.
4
+ //
5
+ // The constants were chosen on 323 idle screenshot pairs from 203 retained hosted-desktop runs.
6
+ // Regions that changed on their own were the panel clock (2 to 6 tiles, every 60 s), a text caret
7
+ // (2 tiles, about every 0.5 s), Chrome's tab spinner (2 to 4 tiles) and one landing page's
8
+ // rotating tagline (10 to 36 tiles, median 18). Page loads, navigations and app updates changed
9
+ // 94 to 2,598 tiles. A moved list selection changed 35 tiles and a re-rendered
10
+ // block of timestamps 8 to 12, both missed by the frame hash in frame-signature.ts.
11
+ //
12
+ // The rule for small regions was chosen on the wave 9 look corpus: 3 pages recorded for 40 minutes
13
+ // each on hosted desktops, replayed over 84 to 168 look timelines a page. There a small region
14
+ // that held still woke the participant falsely beside a panel clock, a relative timestamp
15
+ // ("Opened 3 minutes ago", whose digit ticks each minute and whose tail shifts when the text
16
+ // grows) and a moving chart. With the conditions below it woke none falsely and missed no event.
17
+ /** The side of a square tile, in pixels: one tile is the unit of change. */
18
+ const TILE_PX = 16;
19
+ /**
20
+ * A pixel changed when one of its channels differs by more than this (0 to 255). On the wave 9
21
+ * corpus 32 added a false chart wake, and 48 split a new row's light fill from its text, so a row
22
+ * read as small regions and was seen 69.9 s late.
23
+ */
24
+ const PIXEL_TOLERANCE = 24;
25
+ /**
26
+ * A tile changed when at least this many of its pixels changed. One or two pixels are an
27
+ * antialiased edge: a spinner edge 2 pixels over the tolerance woke a 900 s watch in wave 7 live
28
+ * run 3c. On the wave 9 corpus every tile of its status and export labels changed 10 to 69 pixels,
29
+ * and its new rows (509 and 545 tiles) lost 7 or 8 tiles at 3. 1 and 3 gave the same wakes there.
30
+ */
31
+ const TILE_MIN_PIXELS = 3;
32
+ /**
33
+ * A still region of more than this many tiles wakes the participant at once: it is above every
34
+ * self-changing region measured except 11 of 110 tagline swaps, and below every page change. On
35
+ * the wave 9 corpus the status label (30 tiles) and new rows were large, and the export label (8
36
+ * tiles) small.
37
+ */
38
+ const LARGE_REGION_TILES = 24;
39
+ /**
40
+ * A still region of at most LARGE_REGION_TILES tiles wakes the participant once every tile in it
41
+ * has held still for more than this, and no tile within NEAR_TILES of it ticked or moved with a
42
+ * region of more than MOVING_REGION_TILES tiles in this time. Minute updates (a clock's minutes
43
+ * digit, "2 min ago") move again within it. On the wave 9 corpus a one-time 8-tile text change was
44
+ * seen 72.4 s after it landed (median, 5 s looks).
45
+ */
46
+ const SMALL_REGION_HOLD_MS = 65_000;
47
+ /**
48
+ * How near, in tiles, a ticking tile or a large moving region holds a small region back. The
49
+ * relative timestamp's tail ("1 minute ago" to "2 minutes ago") sat 4 tiles from its digit in
50
+ * wave 7 live run 2a and on the wave 9 corpus, where a distance of 1 left 4 tail wakes in 168
51
+ * timelines and 4 left none.
52
+ */
53
+ const NEAR_TILES = 4;
54
+ /**
55
+ * A tile ticks when its last move came at least this long after the move before it, or when it
56
+ * has moved once. A minute counter ticks. A spinner tile that moves every few looks does not, so a
57
+ * label beside a spinner still wakes the participant, at 90 s on the wave 9 synthetic screens.
58
+ */
59
+ const TICK_GAP_MS = 30_000;
60
+ /**
61
+ * A tile that moves with a connected moving region of more than this many tiles (a chart, a video)
62
+ * holds a small region within NEAR_TILES back. A chart's tiles move every few looks, so they never
63
+ * tick. On the wave 9 corpus, without this condition a chart's tiles woke 3 watches falsely in its
64
+ * page's 40 minutes, and 259 in its 84 replayed timelines; with it, none. It was not tuned apart
65
+ * from LARGE_REGION_TILES and has the same value. A spinner of 1 to 4 tiles stays under it.
66
+ */
67
+ const MOVING_REGION_TILES = 24;
68
+ const UNCHANGED = { verdict: "unchanged" };
69
+ function decode(frame) {
70
+ try {
71
+ const image = PNG.sync.read(frame);
72
+ return image.width > 0 && image.height > 0 ? image : undefined;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ /**
79
+ * Compare later looks at one screen with `reference`, the frame the participant last saw. Call
80
+ * `next` once per look, in order, with the look's time. Each tile that differs from the reference
81
+ * keeps the time it last moved, so a moving tile (a spinner) never joins a still region beside
82
+ * it. Nothing is masked: a large change that stops changing always settles, and a small one does
83
+ * unless a tile near it keeps ticking. Stillness is sampled at looks, so a blink seen in the same
84
+ * phase at every look reads as still.
85
+ */
86
+ export function watchFrames(reference) {
87
+ const base = decode(reference);
88
+ // Each tile that differs from the reference, with its pixels at the look it last moved at. Only
89
+ // these are kept from the previous look, so a watch holds one decoded frame and not two.
90
+ const differing = new Map();
91
+ let moves;
92
+ return {
93
+ next(frame, atMs) {
94
+ const current = decode(frame);
95
+ // A frame that cannot be decoded is compared by its bytes; a frame of another size is a new
96
+ // screen, one change over the whole frame.
97
+ if (base === undefined || current === undefined)
98
+ return frame.equals(reference) ? UNCHANGED : wholeFrame(current ?? base);
99
+ if (current.width !== base.width || current.height !== base.height)
100
+ return wholeFrame(current);
101
+ moves ??= tileMoves(base);
102
+ return compare(base, current, atMs, differing, moves);
103
+ },
104
+ };
105
+ }
106
+ function tileMoves(image) {
107
+ const columns = Math.ceil(image.width / TILE_PX);
108
+ const rows = Math.ceil(image.height / TILE_PX);
109
+ const never = () => new Float64Array(columns * rows).fill(Number.NaN);
110
+ return { columns, rows, moved: never(), movedBefore: never(), movedLarge: never() };
111
+ }
112
+ function wholeFrame(image) {
113
+ return {
114
+ verdict: "settled",
115
+ area: { x: 0, y: 0, width: image?.width ?? 0, height: image?.height ?? 0 },
116
+ small: false,
117
+ };
118
+ }
119
+ function compare(base, current, atMs, differing, moves) {
120
+ const { columns, rows } = moves;
121
+ const still = new Set();
122
+ const movedNow = new Set();
123
+ let moved = 0;
124
+ const move = (tile) => {
125
+ moves.movedBefore[tile] = moves.moved[tile];
126
+ moves.moved[tile] = atMs;
127
+ movedNow.add(tile);
128
+ };
129
+ for (let row = 0; row < rows; row += 1)
130
+ for (let column = 0; column < columns; column += 1) {
131
+ const tile = row * columns + column;
132
+ const before = differing.get(tile);
133
+ if (!framesDiffer(base, current, column, row)) {
134
+ if (before === undefined)
135
+ continue;
136
+ differing.delete(tile);
137
+ // A move back to the reference is a move: a ticking digit can tick back to its old shape.
138
+ if (tileDiffers(before, current, column, row))
139
+ move(tile);
140
+ continue;
141
+ }
142
+ // A tile that starts differing has moved, whatever the previous look's pixels were.
143
+ if (before === undefined || tileDiffers(before, current, column, row)) {
144
+ differing.set(tile, tilePixels(current, column, row));
145
+ move(tile);
146
+ moved += 1;
147
+ }
148
+ else
149
+ still.add(tile);
150
+ }
151
+ for (const region of connectedRegions(movedNow, columns))
152
+ if (region.length > MOVING_REGION_TILES)
153
+ for (const tile of region)
154
+ moves.movedLarge[tile] = atMs;
155
+ const regions = connectedRegions(still, columns);
156
+ const area = (region) => areaOf(region, columns, base.width, base.height);
157
+ const large = largest(regions.filter((region) => region.length > LARGE_REGION_TILES));
158
+ if (large !== undefined)
159
+ return { verdict: "settled", area: area(large), small: false };
160
+ const heldSmall = regions.filter((region) => region.every((tile) => atMs - moves.moved[tile] > SMALL_REGION_HOLD_MS));
161
+ const settled = largest(heldSmall.filter((region) => !heldBack(region, moves, atMs)));
162
+ if (settled !== undefined)
163
+ return { verdict: "settled", area: area(settled), small: true };
164
+ const waiting = largest(heldSmall);
165
+ const held = waiting === undefined ? {} : { held: area(waiting) };
166
+ if (moved > 0)
167
+ return { verdict: "changing", lookSoon: moved > LARGE_REGION_TILES, ...held };
168
+ return { verdict: "unchanged", ...held };
169
+ }
170
+ /**
171
+ * Whether a tile within NEAR_TILES of the region, outside it, ticked or moved with a large region
172
+ * within the hold.
173
+ */
174
+ function heldBack(region, moves, atMs) {
175
+ const inRegion = new Set(region);
176
+ // NaN, a tile that never moved, is never recent.
177
+ const recent = (ms) => atMs - ms <= SMALL_REGION_HOLD_MS;
178
+ for (const tile of region)
179
+ for (let dy = -NEAR_TILES; dy <= NEAR_TILES; dy += 1)
180
+ for (let dx = -NEAR_TILES; dx <= NEAR_TILES; dx += 1) {
181
+ const column = (tile % moves.columns) + dx;
182
+ const row = Math.floor(tile / moves.columns) + dy;
183
+ if (column < 0 || column >= moves.columns || row < 0 || row >= moves.rows)
184
+ continue;
185
+ const near = row * moves.columns + column;
186
+ const moved = moves.moved[near];
187
+ if (inRegion.has(near) || !recent(moved))
188
+ continue;
189
+ const before = moves.movedBefore[near];
190
+ if (Number.isNaN(before) ||
191
+ moved - before >= TICK_GAP_MS ||
192
+ recent(moves.movedLarge[near]))
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+ /** Whether at least TILE_MIN_PIXELS pixels of one tile differ between two frames of one size. */
198
+ function framesDiffer(a, b, column, row) {
199
+ const { width, height } = a;
200
+ const xEnd = Math.min(width, (column + 1) * TILE_PX);
201
+ const yEnd = Math.min(height, (row + 1) * TILE_PX);
202
+ let changed = 0;
203
+ for (let y = row * TILE_PX; y < yEnd; y += 1)
204
+ for (let x = column * TILE_PX; x < xEnd; x += 1) {
205
+ const at = (y * width + x) * 4;
206
+ if ((Math.abs(a.data[at] - b.data[at]) > PIXEL_TOLERANCE ||
207
+ Math.abs(a.data[at + 1] - b.data[at + 1]) > PIXEL_TOLERANCE ||
208
+ Math.abs(a.data[at + 2] - b.data[at + 2]) > PIXEL_TOLERANCE) &&
209
+ ++changed >= TILE_MIN_PIXELS)
210
+ return true;
211
+ }
212
+ return false;
213
+ }
214
+ /** A copy of one tile's RGBA pixels, row by row. */
215
+ function tilePixels(image, column, row) {
216
+ const xStart = column * TILE_PX;
217
+ const xEnd = Math.min(image.width, xStart + TILE_PX);
218
+ const yEnd = Math.min(image.height, (row + 1) * TILE_PX);
219
+ const pixels = new Uint8Array((xEnd - xStart) * (yEnd - row * TILE_PX) * 4);
220
+ let to = 0;
221
+ for (let y = row * TILE_PX; y < yEnd; y += 1) {
222
+ const from = (y * image.width + xStart) * 4;
223
+ pixels.set(image.data.subarray(from, from + (xEnd - xStart) * 4), to);
224
+ to += (xEnd - xStart) * 4;
225
+ }
226
+ return pixels;
227
+ }
228
+ /**
229
+ * Whether at least TILE_MIN_PIXELS pixels of one tile of `image` differ from `pixels`, that tile's
230
+ * earlier pixels.
231
+ */
232
+ function tileDiffers(pixels, image, column, row) {
233
+ const xStart = column * TILE_PX;
234
+ const xEnd = Math.min(image.width, xStart + TILE_PX);
235
+ const yEnd = Math.min(image.height, (row + 1) * TILE_PX);
236
+ const { data } = image;
237
+ let at = 0;
238
+ let changed = 0;
239
+ for (let y = row * TILE_PX; y < yEnd; y += 1)
240
+ for (let x = xStart; x < xEnd; x += 1, at += 4) {
241
+ const from = (y * image.width + x) * 4;
242
+ if ((Math.abs(pixels[at] - data[from]) > PIXEL_TOLERANCE ||
243
+ Math.abs(pixels[at + 1] - data[from + 1]) > PIXEL_TOLERANCE ||
244
+ Math.abs(pixels[at + 2] - data[from + 2]) > PIXEL_TOLERANCE) &&
245
+ ++changed >= TILE_MIN_PIXELS)
246
+ return true;
247
+ }
248
+ return false;
249
+ }
250
+ /** Tiles grouped with every tile of the set next to them, diagonals included. */
251
+ function connectedRegions(tiles, columns) {
252
+ const seen = new Set();
253
+ const regions = [];
254
+ for (const start of tiles) {
255
+ if (seen.has(start))
256
+ continue;
257
+ seen.add(start);
258
+ const region = [];
259
+ const queue = [start];
260
+ for (let tile = queue.pop(); tile !== undefined; tile = queue.pop()) {
261
+ region.push(tile);
262
+ const column = tile % columns;
263
+ for (const dy of [-1, 0, 1])
264
+ for (const dx of [-1, 0, 1]) {
265
+ const neighbourColumn = column + dx;
266
+ const neighbour = tile + dy * columns + dx;
267
+ if (neighbourColumn < 0 || neighbourColumn >= columns)
268
+ continue;
269
+ if (!tiles.has(neighbour) || seen.has(neighbour))
270
+ continue;
271
+ seen.add(neighbour);
272
+ queue.push(neighbour);
273
+ }
274
+ }
275
+ regions.push(region);
276
+ }
277
+ return regions;
278
+ }
279
+ function largest(regions) {
280
+ return regions.reduce((best, region) => (best === undefined || region.length > best.length ? region : best), undefined);
281
+ }
282
+ function areaOf(tiles, columns, width, height) {
283
+ const columnsOf = tiles.map((tile) => tile % columns);
284
+ const rowsOf = tiles.map((tile) => Math.floor(tile / columns));
285
+ const x = Math.min(...columnsOf) * TILE_PX;
286
+ const y = Math.min(...rowsOf) * TILE_PX;
287
+ return {
288
+ x,
289
+ y,
290
+ width: Math.min(width, (Math.max(...columnsOf) + 1) * TILE_PX) - x,
291
+ height: Math.min(height, (Math.max(...rowsOf) + 1) * TILE_PX) - y,
292
+ };
293
+ }
@@ -17,7 +17,6 @@ import { buildObserverData, OBSERVER_DATA_SCHEMA, observerDataFromSaved, } from
17
17
  import { resolveRunPath } from "../run/locate.js";
18
18
  import { loadRunTraces, StreamTraceRefusedError } from "../run/stream-trace.js";
19
19
  import { verifyRun } from "../verify/verify.js";
20
- import {} from "../run/bundle.js";
21
20
  import { exportRedactedBundle } from "./export-bundle.js";
22
21
  import { loadAnalysis } from "../analysis/load.js";
23
22
  import { readNotesForSharing } from "../run/note-files.js";
@@ -1,7 +1,7 @@
1
1
  import { type ActorEnding } from "../actors/stop-cause.js";
2
2
  import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
3
3
  import { type RunDisplay } from "../run/display.js";
4
- import type { RunStream, RunStreamKind } from "../run/streams.js";
4
+ import type { RunStream, RunStreamKind, StoredRunStream } from "../run/streams.js";
5
5
  /**
6
6
  * The Observer's data. From v2 observer-data.json leaves out each participant's items, and from v3
7
7
  * each participant's per-request lists (REQUEST_LISTS in src/run/stream-trace.ts), which no part
@@ -96,7 +96,7 @@ export interface ObserverData {
96
96
  artifactRoot: string;
97
97
  };
98
98
  }
99
- export interface ObserverStream extends RunStream {
99
+ export interface ObserverStream extends StoredRunStream {
100
100
  /** Only the attached server may grant provider-origin access for a live desktop iframe. */
101
101
  embed?: NonNullable<RunStream["embed"]> & {
102
102
  runtimeDesktop?: true;