humanish 0.130.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.
@@ -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
+ }
@@ -41,7 +41,7 @@ export interface DesktopParticipant extends Participant {
41
41
  readonly maxWaitMs?: number;
42
42
  /**
43
43
  * The participant's idleWaitMs, else actor.idleWaitMs: how long a wait with no duration lasts
44
- * while the participant only waits.
44
+ * while the participant only waits, and how often the loop looks at its screen then.
45
45
  */
46
46
  readonly idleWaitMs?: number;
47
47
  /**
@@ -361,7 +361,8 @@ export interface StudyParticipantEntry {
361
361
  windDownMs?: number;
362
362
  /**
363
363
  * Computer use and shared world: this participant's actor.idleWaitMs, how long a wait that names
364
- * no duration lasts while it only waits. Same range, and no longer than actor.maxWaitMs.
364
+ * no duration lasts while it only waits and how often the loop looks at its screen then. Same
365
+ * range, and no longer than actor.maxWaitMs.
365
366
  */
366
367
  idleWaitMs?: number;
367
368
  /**
@@ -404,16 +405,19 @@ export interface StudyActor {
404
405
  maxOutputTokens?: number;
405
406
  /**
406
407
  * Computer-use and shared-world: the longest one `wait` action lasts, in whole milliseconds from
407
- * 1000 to 600000. Default 120000, or 30000 on a desktop with speech. A longer wait is shortened
408
- * to it, the trace records a `wait shortened` notice, and the participant is told on its next
409
- * turn.
408
+ * 1000 to 3600000. Default 120000, or 30000 on a desktop with speech. In a turn with another
409
+ * action a longer wait is shortened to it, the trace records a `wait shortened` notice, and the
410
+ * participant is told on its next turn. A turn that only waits is watched: its named waits last
411
+ * their lengths, together up to this or to 600000 when it is not set, and on a screen that stays
412
+ * the same its watches in a row grow to the same cap.
410
413
  */
411
414
  maxWaitMs?: number;
412
415
  /**
413
416
  * Computer-use and shared-world: how long a `wait` that names no duration lasts when the
414
- * participant's turn only waits or takes screenshots, in whole milliseconds from 1000 to 600000
415
- * and no longer than maxWaitMs. Default 5000. After an action in the same turn such a wait lasts
416
- * 500 ms. OpenAI's computer-use `wait` never names a duration.
417
+ * participant's turn only waits or takes screenshots, and how often the loop looks at the screen
418
+ * while it watches such a turn, in whole milliseconds from 1000 to 600000 and no longer than
419
+ * maxWaitMs. Default 5000. After an action in the same turn such a wait lasts 500 ms. OpenAI's
420
+ * computer-use `wait` never names a duration.
417
421
  */
418
422
  idleWaitMs?: number;
419
423
  /**
@@ -66,7 +66,7 @@ const ACTOR_ROWS = [
66
66
  },
67
67
  {
68
68
  field: "actor.idleWaitMs",
69
- reason: "how long a computer-use participant's wait with no duration lasts; needs route: computer-use or shared-world",
69
+ reason: "how long a computer-use participant's wait with no duration lasts and how often it looks while it waits; needs route: computer-use or shared-world",
70
70
  applies: (config, routes) => config.actor?.idleWaitMs !== undefined && !routes.cua && !routes.shared,
71
71
  },
72
72
  ];
@@ -168,6 +168,14 @@ export interface ActorTraceItem {
168
168
  screenshotRef?: { path: string; redaction: "blurred" | "ocr_scrubbed" | "none" };
169
169
  text?: string; // redacted
170
170
  page?: { origin: string; path: string }; // computer-use screenshot items; redacted, no query
171
+ // computer-use: the wait ui_action during which a watch of a turn that only waited ended
172
+ watch?: {
173
+ limitMs: number;
174
+ askedMs: number; // what the turn's waits asked for; above limitMs past the watch's growth cap
175
+ wake: "changed" | "speech" | "condition" | "limit" | "clock" | "stalled" | "stopped";
176
+ looks: number;
177
+ area?: { x: number; y: number; width: number; height: number }; // pixels, coordinates only
178
+ };
171
179
  }
172
180
 
173
181
  export interface ActorCapabilities {
@@ -104,15 +104,17 @@ title, text, fractional scroll position and heard speech; those remain
104
104
  runtime-only under the existing loop contract. Arbitrary `appState` is refused
105
105
  because v1 has no closed schema for it. The protocol does not truncate strings,
106
106
  round coordinates, or silently drop unsupported state. A participant may ask for a
107
- longer wait than one request carries. The computer-use loop shortens it to the study's
108
- `actor.maxWaitMs` (by default two minutes, or 30 seconds on a desktop with speech, whose heard
109
- speech reaches the participant only with a screenshot), records a `wait shortened` notice with both
110
- durations when it does, and sends the rest as consecutive waits of at most 30 seconds. The Codex
111
- participant's `humanish_ui` schema leaves a wait's length open for that reason; the wire still
112
- refuses a single wait over 30 seconds. A wait that names no duration, as every OpenAI computer-use
113
- wait does, reaches the desktop with a length too: the study's `actor.idleWaitMs` (5 seconds by
114
- default) when the participant's turn only waits or takes screenshots, and 500 ms after an action in
115
- the same turn.
107
+ longer wait than one request carries. In a turn with another action, the computer-use loop
108
+ shortens it to the study's `actor.maxWaitMs` (by default two minutes, or 30 seconds on a desktop
109
+ with speech, whose heard speech reaches the participant only with a screenshot), records a `wait
110
+ shortened` notice with both durations when it does, and sends the rest as consecutive waits of at
111
+ most 30 seconds. The Codex participant's `humanish_ui` schema leaves a wait's length open for that
112
+ reason; the wire still refuses a single wait over 30 seconds. A wait that names no duration, as
113
+ every OpenAI computer-use wait does, reaches the desktop with a length too: 500 ms after an action
114
+ in the same turn. A turn that only waits or takes screenshots sends no wait to the desktop: the
115
+ loop observes the screen every `actor.idleWaitMs` until it changes or the wait's time passes
116
+ (`src/actors/computer-use/loop/watch.ts`), so a waiting participant costs one observe request per
117
+ look.
116
118
 
117
119
  Click, double-click, move, scroll and drag may carry `heldKeys`, which the
118
120
  OpenAI provider maps from its computer tool's `keys`. The Codex participant's
@@ -263,8 +263,10 @@ The rest of this section names keys by their v3 spelling:
263
263
  the end, a turn that requests no action does not end the session: the trace records a
264
264
  `participant ended before its scheduled end` notice, the next request says the time left, and
265
265
  the turn counts toward the idle backstop. Before the end, a turn that only waits or takes
266
- screenshots counts toward neither backstop streak (`advanceBackstop`'s `waitingIsTheTask`). The
267
- plan entry carries `endAfterMs`, and `study check` prints a `windows` row;
266
+ screenshots counts toward neither backstop streak (`advanceBackstop`'s `waitingIsTheTask`). A
267
+ watch that reaches the end stops there with no further look, and a backstop streak that trips on
268
+ a turn reaching the end gives way to the end. The plan entry carries `endAfterMs`, and `study
269
+ check` prints a `windows` row;
268
270
  - `participants[].maxSessionMs` (computer-use only, also on `count:` groups): the longest the
269
271
  participant's session lasts, in whole milliseconds from 1 to 86,400,000, counted from the start
270
272
  of its own session. `windDownMs` applies to it as to `endAfterMs`. Refused: a value outside that
@@ -282,7 +284,9 @@ The rest of this section names keys by their v3 spelling:
282
284
  `cap=<duration>`;
283
285
  - `participants[].idleWaitMs` (computer-use and shared-world, also on `count:` groups): this
284
286
  participant's `actor.idleWaitMs`, in the same range and refused above an explicit
285
- `actor.maxWaitMs` (`waitLimitValidationReason`). Its trace records it in `waitSettings`;
287
+ `actor.maxWaitMs` (`waitLimitValidationReason`): how long its wait with no duration lasts in a
288
+ turn that only waits, and how often the loop looks at its screen while it watches such a turn.
289
+ Its trace records it in `waitSettings`;
286
290
  - `actor.clock.timeZone` (computer-use and shared-world): a tz database time zone name, such as `Europe/London`. Each turn's context hint carries
287
291
  the local time in it, and a scheduled end or session cap is given in it (UTC without it). On a hosted desktop the
288
292
  participant's browser launches with `TZ` set to it (`openParticipantSurface` in
@@ -1067,6 +1071,35 @@ Core-owned fields:
1067
1071
  click-like `ui_action` items, structured `coord` (`x`/`y`). Both are additive:
1068
1072
  absent on older bundles and non-stamping producers, and absence means
1069
1073
  "timing/position unrecorded", never zero
1074
+ - optional `items[].watch` (`ActorWatch` in `src/actors/contract.ts`), on the
1075
+ computer-use `ui_action` of the wait during which a watch ended. A turn whose
1076
+ actions are only waits and screenshots, and that asks for wait time, sends no
1077
+ wait to the desktop: the loop looks at the screen every `waitSettings.idleWaitMs`
1078
+ and requests the next turn when a change holds still, speech arrives, a
1079
+ `stopWhen` rule, `dwell.when` or task `success` rule matches a look, the
1080
+ participant's wind-down or end arrives, or the watch's time passes
1081
+ (`src/actors/computer-use/loop/watch.ts`). A change of more than 24 tiles of
1082
+ 16 pixels counts once it holds still at a look; one of 24 tiles or fewer once
1083
+ it has held still for more than 65 s with no tile within 4 tiles of it ticking
1084
+ or moving with a region of more than 24 tiles
1085
+ (`src/evidence/frame-change.ts`). The first watch lasts the turn's planned
1086
+ length: the named waits' lengths, together up to `actor.maxWaitMs` (else
1087
+ 600000), and `idleWaitMs` for each wait that names none. Each further one in a row that ended
1088
+ at its limit, or that a change of 24 tiles or fewer woke, lasts twice as long,
1089
+ up to the larger of its planned length and that cap. `limitMs` is how long it
1090
+ could last; `askedMs` is what the turn's waits asked for, the same sum with no
1091
+ cap, so it is above `limitMs` when the named waits passed the cap and below it
1092
+ when the watch grew; `wake` is `changed`, `speech`,
1093
+ `condition`, `limit`, `clock` (wind-down or end), `stalled` (a look got no
1094
+ frame; the next observation is a fresh one), or `stopped` (the study budget or
1095
+ the session's time ran out); `looks` counts its looks; `area` (`x`, `y`,
1096
+ `width`, `height` in pixels) bounds the change that woke it and is absent for
1097
+ other wakes and for a change still moving at the last look. A `notice` titled
1098
+ `watching the screen`, `lifecycle: started`, opens each watch. The turn's
1099
+ waits share the watch in order, in proportion to their planned lengths, and
1100
+ waits after the one carrying `watch` are titled `wait 0ms`. The look that
1101
+ ended a watch is the turn's `turn-NN` screenshot; other looks are not kept.
1102
+ Additive: absent on plain waits, other producers and older bundles
1070
1103
  - optional `items[].page` and `finalPage` (`{ origin, path }`, `TracePage` in
1071
1104
  `src/actors/contract.ts`): where a computer-use participant's browser was. The loop puts `page`
1072
1105
  on the `screenshot` item of each observation that read a URL: `turn-00-start` is the page before
@@ -1121,13 +1154,20 @@ Core-owned fields:
1121
1154
  `stopCause: provider_output_limit`.
1122
1155
  - optional `waitSettings`: the wait lengths a computer-use participant's session
1123
1156
  applied, in milliseconds, defaults included. `maxWaitMs` is the longest one
1124
- wait action lasts (`actor.maxWaitMs`); a longer wait is shortened to it.
1157
+ wait action lasts (`actor.maxWaitMs`, else 120000, or 30000 on a desktop with
1158
+ speech); in a turn with another action a longer wait is shortened to it.
1125
1159
  `idleWaitMs` is how long a wait that names no duration lasts in a turn that
1126
1160
  only waits or takes screenshots (`participants[].idleWaitMs`, else
1127
- `actor.idleWaitMs`), and `settleWaitMs` how
1128
- long such a wait lasts after an action in the same turn. Each `ui_action` for
1129
- a wait is titled with the length it lasted. Absent on other routes and on
1130
- earlier bundles
1161
+ `actor.idleWaitMs`), and the time between looks at the screen while the loop
1162
+ watches such a turn, at most 30000 on a desktop with speech. `settleWaitMs` is
1163
+ how long a wait that names no duration lasts after an action in the same turn,
1164
+ and the time to a watch's quick look after a large change. A watch's length
1165
+ grows to `actor.maxWaitMs`, else 600000, which `waitSettings` does not record;
1166
+ each watch's `watch.limitMs` does. The named waits of a turn that only waits
1167
+ are watched for their lengths, together up to that cap, past `maxWaitMs`. A wait sent to the desktop has a
1168
+ `ui_action` titled with its planned length, which a stalled wait keeps. A
1169
+ watched wait's `ui_action` is titled with the part of the watch it covered.
1170
+ Absent on other routes and on earlier bundles
1131
1171
  - optional `conversation`: how the first-party OpenAI computer-use provider
1132
1172
  carried the conversation between requests. `mode` is `threaded` (the server
1133
1173
  kept it through `previous_response_id`) or `explicit_context` (the server kept
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.130.0",
3
+ "version": "0.131.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",