simframe 0.10.0 → 0.11.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.
package/src/metrics.js CHANGED
@@ -119,7 +119,7 @@ export const readFlows = (udid, opts) => readJsonl(metricPaths(udid).flows, opts
119
119
  * matching error strings at the boundary — a regexed message is a reason that
120
120
  * silently becomes "unknown" the day somebody rewords it.
121
121
  */
122
- export function tag(err, reason, { candidates = [], tried = [], ambiguous = false } = {}) {
122
+ export function tag(err, reason, { candidates = [], tried = [], ambiguous = false, intent = null } = {}) {
123
123
  if (!REASONS.includes(reason)) throw new Error(`not an escalation reason: ${reason}`);
124
124
  // `ambiguous` is narrower than the reason, and that is the point. Two very
125
125
  // different failures both tag `ambiguous_intent`: the target is on screen
@@ -127,7 +127,19 @@ export function tag(err, reason, { candidates = [], tried = [], ambiguous = fals
127
127
  // thought we knew. Only the first is resolvable by *choosing*, and only the
128
128
  // first tells a waiting caller that waiting is pointless — the thing it is
129
129
  // waiting for has already arrived.
130
- err.escalation = { reason, candidates, tried, ambiguous };
130
+ // `intent` is the goal in the caller's own words, recorded as a field rather
131
+ // than left in the prose of `detail`.
132
+ //
133
+ // Phase 17's go/no-go asks whether an on-device model would pick the element
134
+ // Claude picked, given the goal and the element list. The element list is
135
+ // here as `candidates` and the eventual choice is recoverable from the
136
+ // graph — the tap that finally worked on this screen becomes a verified edge
137
+ // carrying its own step. The goal was the missing third, and it was sitting
138
+ // inside a sentence: `"X" matches 3 things on this screen — say which…`.
139
+ // Regexing it back out at export time is the exact habit this file exists to
140
+ // avoid, and it would silently return nothing the day that sentence is
141
+ // reworded.
142
+ err.escalation = { reason, candidates, tried, ambiguous, intent };
131
143
  return err;
132
144
  }
133
145
 
@@ -252,7 +264,20 @@ export function fingerprintNow(udid, screenmap) {
252
264
  * file is committed to a public repo in summary form, and the question it has
253
265
  * to answer is "was this all one agent", which needs no identity to answer.
254
266
  */
255
- const SESSION_ID = `${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
267
+ const SESSION_ID = process.env.SIMFRAME_SESSION
268
+ ? String(process.env.SIMFRAME_SESSION).slice(0, 64)
269
+ : `${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
270
+
271
+ /*
272
+ * Minting the id from the pid was right for the MCP server, which is one
273
+ * long-lived process, and wrong for everything else. A CLI-driven agent starts
274
+ * a process per command, so it got one "session" per command: on the benchmark
275
+ * device, 33 session ids for 46 records, 30 of them holding a single record.
276
+ * `escalations --session` was therefore unable to answer the one question it
277
+ * exists for, and Phase 17's go/no-go step 1 — "filter to one session id" —
278
+ * had nothing to filter. `SIMFRAME_SESSION` lets a caller that knows it is one
279
+ * session say so; the per-process id stays the default.
280
+ */
256
281
 
257
282
  /** How this process is being used, for reading a breakdown afterwards. */
258
283
  function clientKind() {
@@ -271,6 +296,7 @@ export const clientName = () => CLIENT;
271
296
  export function recordEscalation(udid, {
272
297
  flowId = null,
273
298
  flowName = null,
299
+ intent = null,
274
300
  stepIndex = null,
275
301
  fingerprint = null,
276
302
  reason,
@@ -292,6 +318,9 @@ export function recordEscalation(udid, {
292
318
  client: CLIENT,
293
319
  flow_id: flowId,
294
320
  flow_name: flowName,
321
+ // What was asked for, in the caller's words. Ground truth for Phase 17's
322
+ // go/no-go, and on its own it answers "what kind of decision is costing us".
323
+ intent: intent ? String(intent).slice(0, 120) : null,
295
324
  step_index: stepIndex,
296
325
  screen_fingerprint: fingerprint,
297
326
  reason,
package/src/ocr.js CHANGED
@@ -19,6 +19,17 @@ const BIN = path.join(BIN_DIR, 'ocr');
19
19
 
20
20
  let ready = null;
21
21
 
22
+ /**
23
+ * Which recognition level to ask Vision for.
24
+ *
25
+ * `accurate` is the default and what CLAUDE.md fixes; `fast` is the other thing
26
+ * Vision offers. Exposed so the pair can be scored against each other instead
27
+ * of one of them being a constant nobody measured.
28
+ */
29
+ export function level() {
30
+ return String(process.env.SIMFRAME_OCR ?? '').toLowerCase() === 'fast' ? 'fast' : 'accurate';
31
+ }
32
+
22
33
  /** Compile once, then reuse. Recompiles only if the source is newer than the binary. */
23
34
  export async function ensureBinary() {
24
35
  if (ready) return ready;
@@ -55,7 +66,13 @@ export async function ensureBinary() {
55
66
  export async function readText(pngFile, { density = 3 } = {}) {
56
67
  const built = await ensureBinary();
57
68
  if (!built.available) throw new Error(built.reason);
58
- const { stdout } = await run(built.binary, [pngFile], { timeout: 30_000, maxBuffer: 16 << 20 });
69
+ // The recognition level rides in the environment rather than in argv, so the
70
+ // Swift side keeps its one-argument contract and an older binary still works.
71
+ const { stdout } = await run(built.binary, [pngFile], {
72
+ timeout: 30_000,
73
+ maxBuffer: 16 << 20,
74
+ env: { ...process.env, SIMFRAME_OCR: level() },
75
+ });
59
76
  const raw = JSON.parse(stdout || '[]');
60
77
  return raw.map((r) => ({
61
78
  text: r.text,
package/src/planner.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The local planner tier: a ranker, behind a flag, that may only reorder.
3
+ *
4
+ * **Why this exists at all, given Phase 17 was a no-go.** That phase asked a
5
+ * local model to *choose the next element*, and the answer was that the matcher
6
+ * already does — 37 of 40 real decisions. This is the complement and the one
7
+ * case a string matcher structurally cannot do: the goal matches **nothing** on
8
+ * screen, and something has to guess which container leads to it. "Change my
9
+ * username" shares no prefix, synonym or typo distance with "Account".
10
+ *
11
+ * Measured on this machine, six hand-written cases: 5 of 6 top-1, 6 of 6 top-3,
12
+ * median 564 ms warm. See `docs/BENCHMARKS.md`. Against a model round trip at
13
+ * 10–16 s that is roughly twenty times cheaper; against the honest baseline —
14
+ * breadth-first ordering, which needs no model — it won five of six.
15
+ *
16
+ * **What it is allowed to do, and it is deliberately almost nothing.** It
17
+ * reorders a list of candidates the caller has already permitted and will try
18
+ * in some order regardless. It cannot invent a label, cannot choose an action,
19
+ * cannot see pixels, and never runs on a destructive label because the caller
20
+ * filtered those out before asking (`src/vocabulary.js`). If it is wrong the
21
+ * exploration budget simply tries the next one. That is strictly weaker
22
+ * authority than Phase 17 proposed, which is what makes it safe to try.
23
+ *
24
+ * **Off unless asked.** `SIMFRAME_PLANNER=apple` turns it on; anything else,
25
+ * or any failure at all, degrades to `null` and the caller keeps its own order.
26
+ * `doctor` reports which. CI runs with it off.
27
+ */
28
+ import { spawn, execFile } from 'node:child_process';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { promisify } from 'node:util';
33
+ import * as store from './store.js';
34
+
35
+ const run = promisify(execFile);
36
+ const SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'native', 'rank.swift');
37
+ const BIN = path.join(store.ROOT, 'bin', 'rank');
38
+
39
+ /** Which backend the caller asked for. Absent means no local planner. */
40
+ export function requested(options) {
41
+ // Per call first, then the environment, for the reason in `sensorMode`: an
42
+ // MCP server's environment is fixed when it spawns, so a tester could not
43
+ // switch backends inside one session and a round came back with one arm of
44
+ // its A/B unrun.
45
+ const raw = String(options?.planner ?? process.env.SIMFRAME_PLANNER ?? '').trim().toLowerCase();
46
+ if (!raw || raw === 'none' || raw === 'off' || raw === '0' || raw === 'false') return null;
47
+ return raw;
48
+ }
49
+
50
+ let building = null;
51
+
52
+ export async function ensureBinary() {
53
+ if (building) return building;
54
+ building = (async () => {
55
+ try {
56
+ const src = fs.statSync(SOURCE).mtimeMs;
57
+ const bin = fs.existsSync(BIN) ? fs.statSync(BIN).mtimeMs : 0;
58
+ if (bin > src) return { available: true, binary: BIN };
59
+ } catch {
60
+ return { available: false, reason: 'the ranker source is missing from this install' };
61
+ }
62
+ try {
63
+ fs.mkdirSync(path.dirname(BIN), { recursive: true });
64
+ // `swiftc`, not `xcrun swiftc`: nothing above the platform boundary may
65
+ // name a platform tool, and the boundary test catches it. `src/ocr.js`
66
+ // set this precedent — a compiler is not a device tool.
67
+ await run('swiftc', ['-O', SOURCE, '-o', BIN], { timeout: 180_000 });
68
+ return { available: true, binary: BIN };
69
+ } catch (err) {
70
+ building = null; // let a later call retry once a toolchain is present
71
+ return {
72
+ available: false,
73
+ reason: err.code === 'ENOENT'
74
+ ? 'swiftc is not installed, so the local planner cannot be built (install Xcode command line tools)'
75
+ : `could not build the local planner: ${String(err.message).split('\n')[0]}`,
76
+ };
77
+ }
78
+ })();
79
+ return building;
80
+ }
81
+
82
+ let session = null;
83
+
84
+ /** Start the helper once and keep it, because the first answer pays model load. */
85
+ async function open() {
86
+ if (session) return session;
87
+ const built = await ensureBinary();
88
+ if (!built.available) return { ok: false, reason: built.reason };
89
+ session = await new Promise((resolve) => {
90
+ const child = spawn(built.binary, [], { stdio: ['pipe', 'pipe', 'ignore'] });
91
+ // Deliberately NOT unref'd. Unreffing the child's stdout unreferences the
92
+ // very pipe every request waits on, so the process exited silently in the
93
+ // middle of an await — a flow that printed nothing and returned 0. The
94
+ // helper is closed explicitly instead, by whoever opened it.
95
+ let buffer = '';
96
+ const waiters = [];
97
+ let settled = false;
98
+ const fail = (reason) => {
99
+ if (!settled) { settled = true; resolve({ ok: false, reason }); }
100
+ while (waiters.length) waiters.shift()(null);
101
+ };
102
+ child.on('error', (err) => fail(`the local planner would not start: ${err.message}`));
103
+ child.on('exit', () => { session = null; fail('the local planner exited'); });
104
+ child.stdout.on('data', (chunk) => {
105
+ buffer += chunk;
106
+ let i = buffer.indexOf('\n');
107
+ while (i >= 0) {
108
+ const line = buffer.slice(0, i).trim();
109
+ buffer = buffer.slice(i + 1);
110
+ i = buffer.indexOf('\n');
111
+ if (!line) continue;
112
+ let msg;
113
+ try { msg = JSON.parse(line); } catch { continue; }
114
+ if (!settled) {
115
+ settled = true;
116
+ if (msg.ready) resolve({ ok: true, child, waiters });
117
+ else resolve({ ok: false, reason: msg.unavailable ?? 'the local planner did not become ready' });
118
+ continue;
119
+ }
120
+ const next = waiters.shift();
121
+ if (next) next(msg);
122
+ }
123
+ });
124
+ });
125
+ return session;
126
+ }
127
+
128
+ /**
129
+ * Reorder `options` by which is likeliest to lead to `goal`.
130
+ *
131
+ * @returns {Promise<string[]|null>} the caller's own order is correct when this
132
+ * is null, which is every failure mode: flag off, no model, a timeout, a
133
+ * parse problem, a paraphrasing answer. Never throws.
134
+ */
135
+ export async function rank(goal, options, { timeoutMs = 3000, deviceOptions } = {}) {
136
+ if (!requested(deviceOptions)) return null;
137
+ if (!goal || !Array.isArray(options) || options.length < 2) return null;
138
+ let live;
139
+ try {
140
+ live = await open();
141
+ } catch {
142
+ return null;
143
+ }
144
+ if (!live?.ok) return null;
145
+ const answer = await new Promise((resolve) => {
146
+ // A timed-out waiter has to be *retired*, not merely resolved. Leaving it in
147
+ // the queue meant the next answer went to it instead of to the next asker,
148
+ // and every call after that was off by one — which showed up as an
149
+ // exploration run that never finished rather than as an error.
150
+ let done = false;
151
+ const waiter = (msg) => {
152
+ if (done) return;
153
+ done = true;
154
+ clearTimeout(timer);
155
+ resolve(msg);
156
+ };
157
+ const timer = setTimeout(() => {
158
+ if (done) return;
159
+ done = true;
160
+ const i = live.waiters.indexOf(waiter);
161
+ if (i >= 0) live.waiters.splice(i, 1);
162
+ resolve(null);
163
+ }, timeoutMs);
164
+ live.waiters.push(waiter);
165
+ try {
166
+ live.child.stdin.write(`${JSON.stringify({ goal: String(goal), options })}\n`);
167
+ } catch {
168
+ clearTimeout(timer);
169
+ resolve(null);
170
+ }
171
+ });
172
+ if (!answer?.order?.length) return null;
173
+ // It often returns a subset, so its order comes first and ours fills the tail.
174
+ // Trusting it to be exhaustive would silently drop candidates the budget was
175
+ // going to try.
176
+ const ranked = answer.order.filter((label) => options.includes(label));
177
+ const seen = new Set(ranked);
178
+ return [...ranked, ...options.filter((o) => !seen.has(o))];
179
+ }
180
+
181
+ /** For `doctor`: what the planner layer is, in one line. */
182
+ export async function status(options) {
183
+ const want = requested(options);
184
+ if (!want) return { planner: 'none', detail: 'not requested (SIMFRAME_PLANNER is unset)' };
185
+ if (want !== 'apple') return { planner: 'none', detail: `no such planner backend: "${want}"` };
186
+ const live = await open();
187
+ if (!live?.ok) return { planner: 'none', detail: live?.reason ?? 'unavailable' };
188
+ return { planner: 'apple', detail: 'Apple Foundation Models, on-device, ranking only' };
189
+ }
190
+
191
+ /** Let a process exit without waiting on the helper. */
192
+ export function close() {
193
+ try { session?.child?.kill(); } catch { /* already gone */ }
194
+ session = null;
195
+ }
@@ -183,7 +183,8 @@ async function resolveDevice(query, opts) {
183
183
  new Error(
184
184
  `${booted.length} emulators are running and none was named: ` +
185
185
  `${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
186
- 'or set SIMFRAME_DEVICE to pick a default for this shell',
186
+ 'or set SIMFRAME_DEVICE to pick a default for this shell. Over MCP there is no shell: ' +
187
+ 'pass "device" once on any call and the rest of the session remembers it',
187
188
  ),
188
189
  { ambiguous: true },
189
190
  );
@@ -87,7 +87,8 @@ async function resolveDevice(query, opts) {
87
87
  new Error(
88
88
  `${booted.length} simulators are booted and none was named: ` +
89
89
  `${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
90
- 'or set SIMFRAME_DEVICE to pick a default for this shell',
90
+ 'or set SIMFRAME_DEVICE to pick a default for this shell. Over MCP there is no shell: ' +
91
+ 'pass "device" once on any call and the rest of the session remembers it',
91
92
  ),
92
93
  { ambiguous: true },
93
94
  );
package/src/png.js CHANGED
@@ -178,6 +178,32 @@ export function grayGrid(bmp, cols, rows) {
178
178
  }
179
179
 
180
180
  /** Nearest-neighbour scale. Only used for contact sheets, where speed beats quality. */
181
+ /**
182
+ * A rectangle out of a bitmap, clamped to it.
183
+ *
184
+ * Exists because a whole screen at 1024px on the long edge cannot answer a
185
+ * question about one control. Reported from a real session: a selected filter
186
+ * chip and an unselected one are indistinguishable at that size, and selection
187
+ * state was the entire question the ticket turned on — so the agent shelled out
188
+ * to `simctl io` and PIL to crop and upscale the chip row, **for every single
189
+ * check**. Their estimate: six round trips.
190
+ *
191
+ * Coordinates are pixels; the caller converts from points, because only the
192
+ * caller knows the density it read them at.
193
+ */
194
+ export function cropBitmap(bmp, x, y, width, height) {
195
+ const left = Math.max(0, Math.min(bmp.width - 1, Math.round(x)));
196
+ const top = Math.max(0, Math.min(bmp.height - 1, Math.round(y)));
197
+ const w = Math.max(1, Math.min(bmp.width - left, Math.round(width)));
198
+ const h = Math.max(1, Math.min(bmp.height - top, Math.round(height)));
199
+ const out = Buffer.alloc(w * h * 4);
200
+ for (let row = 0; row < h; row += 1) {
201
+ const from = ((top + row) * bmp.width + left) * 4;
202
+ bmp.data.copy(out, row * w * 4, from, from + w * 4);
203
+ }
204
+ return { width: w, height: h, data: out };
205
+ }
206
+
181
207
  export function scaleBitmap(bmp, width, height) {
182
208
  const out = Buffer.allocUnsafe(width * height * 4);
183
209
  for (let y = 0; y < height; y++) {
package/src/refs.js CHANGED
@@ -104,17 +104,46 @@ export function parseSelector(query) {
104
104
  * numbering introduces that labels do not have, and a ref resolved against the
105
105
  * wrong screen taps whatever now happens to sit at those coordinates.
106
106
  */
107
- export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, tolerance = REF_TOLERANCE } = {}) {
107
+ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, structuralDistance = 0, tolerance = REF_TOLERANCE } = {}) {
108
108
  const table = readRefs(udid);
109
109
  if (!table) throw new Error(`#${n} means nothing yet — read the screen first (sim_ui, or simframe ui)`);
110
- const stale = (was, now) =>
111
- `#${n} was numbered on a different screen (${was} → ${now}) — read the screen again before using refs`;
110
+ // The label this number was given to, when there is one. A stale ref is not
111
+ // nothing: the table records what it pointed at, which is enough for the
112
+ // caller to be offered the label instead of a bare refusal.
113
+ const labelFor = table.refs?.find((r) => r.ref === n)?.label ?? null;
114
+ // How long ago these numbers were handed out. Asked for by name: "refs
115
+ // expired (issued 4 calls ago) is actionable in a way this isn't".
116
+ const issued = Number.isFinite(table.at) ? ` refs were numbered ${Math.round((Date.now() - table.at) / 1000)}s ago;` : '';
117
+ // `staleKind` is the difference between "these numbers were drawn on a screen
118
+ // that has since shifted" and "you are somewhere else entirely", and only the
119
+ // first may be recovered by re-resolving the label the number stood for.
120
+ // Both wore the same flag once, and the caller re-resolved across an app
121
+ // switch: `#1` had been "Reminders" in Contacts, matched the status-bar
122
+ // back-to-app breadcrumb "• Reminders" at 0.64, and returned a tappable point
123
+ // in the status bar — a region the map itself refuses to offer. A refusal had
124
+ // become a confident wrong answer.
125
+ const staleError = (why, kind) => Object.assign(
126
+ new Error(`#${n} cannot be trusted here —${issued} ${why}. Read the screen again (sim_ui) to renumber`),
127
+ { staleRef: true, staleLabel: labelFor, staleKind: kind },
128
+ );
112
129
 
113
130
  // Structural identity first, because it is the question actually being asked:
114
131
  // is this the screen those numbers were assigned on? The caller gets it
115
132
  // cheaply — screen memory is a file read, not a perception pass.
116
- if (table.structuralHash && structuralHash && table.structuralHash !== structuralHash) {
117
- throw new Error(stale(table.structuralHash.slice(0, 8), structuralHash.slice(0, 8)));
133
+ //
134
+ // But only when the recall that produced it was exact. The identity arrives
135
+ // from `recallNearest`, which matches by layout within a tolerance so that a
136
+ // list with new rows stays one screen; above distance zero it is therefore a
137
+ // guess about *which* remembered screen this is, and a guess cannot be the
138
+ // sole reason to refuse. That mismatch was reported from the field as a
139
+ // refusal on unchanged state — the map had named the screen from the tolerant
140
+ // recall and printed the same header before and after, while this check read
141
+ // the same recall as exact and disagreed with it. Beyond distance zero the
142
+ // pixel backstop below is the one that decides, which is what it is for.
143
+ const exactRecall = structuralDistance === 0 || structuralDistance == null;
144
+ if (exactRecall && table.structuralHash && structuralHash && table.structuralHash !== structuralHash) {
145
+ throw staleError(`this is a different screen (${table.structuralHash.slice(0, 8)}`
146
+ + ` → ${structuralHash.slice(0, 8)})`, 'identity');
118
147
  }
119
148
  // Nothing recognises the screen we are on, so nothing can vouch for the
120
149
  // numbers. Refusing costs a re-read; guessing taps whatever is at those
@@ -128,9 +157,23 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, t
128
157
  // other — measured: refs numbered on the springboard resolved happily on a
129
158
  // different screen because both hashes were degenerate. A hash with almost
130
159
  // no bits set is not evidence of anything.
131
- if (layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
132
- && hashDistance(table.layoutHash, layoutHash) > tolerance) {
133
- throw new Error(stale(table.layoutHash.slice(0, 8), layoutHash.slice(0, 8)));
160
+ // Reported three times in one session as `#4 was numbered on a different
161
+ // screen (03003714 → 03003714)` — a message that says the screen changed
162
+ // while showing that it did not, and left the reporter unable to tell a real
163
+ // move from a false positive. The cause was this branch printing eight
164
+ // characters of a **72-character** perceptual hash: its leading characters
165
+ // encode coarse structure, which is the very reason this comparison is a
166
+ // distance against a tolerance rather than an equality, so prefixes coincide
167
+ // routinely while the hashes differ. So this says the distance, and says that
168
+ // it is pixels rather than identity — a different thing from the branch above,
169
+ // which had been wearing the same sentence.
170
+ const drift = layoutHash && table.layoutHash && informative(table.layoutHash) && informative(layoutHash)
171
+ ? hashDistance(table.layoutHash, layoutHash)
172
+ : null;
173
+ if (drift != null && drift > tolerance) {
174
+ throw staleError(`the screen has moved too far from where these refs were numbered`
175
+ + ` (layout distance ${drift}, tolerance ${tolerance}) — the identity may be unchanged;`
176
+ + ' this is a pixel measurement, not a different screen', 'drift');
134
177
  }
135
178
  const hit = table.refs.find((r) => r.ref === n);
136
179
  if (!hit) {
package/src/regions.js CHANGED
@@ -36,6 +36,8 @@ const STATUS_BAR_FRACTION = 0.065;
36
36
 
37
37
  /** Keyboards occupy the bottom of the screen and are unusually tall. */
38
38
  const KEYBOARD_MIN_FRACTION = 0.28;
39
+ /** Most of a keyboard is keys. Below this it is a list that happens to be small. */
40
+ const KEYBOARD_MIN_KEYISH = 0.6;
39
41
 
40
42
  /** Chrome is short. A 90pt list cell is not a tab item however low it sits. */
41
43
  const CHROME_MAX_HEIGHT_FRACTION = 0.075;
@@ -243,6 +245,50 @@ export function navSlot(frame, screen) {
243
245
  * common case and must stay cheap. This was the first band derived from the
244
246
  * elements rather than from a fraction, and it is the model the rest now follow.
245
247
  */
248
+ /**
249
+ * Whether an element is shaped like a key rather than like content.
250
+ *
251
+ * The canonical version of this test, because two places need it and getting
252
+ * them out of step is what produced the bug below. A key is finger-sized and
253
+ * says almost nothing: a single character, a short named key, or nothing at
254
+ * all. A row of content is wider, or carries words.
255
+ */
256
+ export const KEY_MAX_WIDTH = 120;
257
+
258
+ const NAMED_KEY = /^(space|return|enter|shift|delete|backspace|done|globe|dictate|emoji|caps ?lock|number|numbers|symbols|letters|more|search|go|send|join|route|abc|123)$/i;
259
+
260
+ export function looksLikeKey(t) {
261
+ if (/^key$/i.test(String(t?.type ?? ''))) return true;
262
+ const width = t?.frame?.width;
263
+ if (Number.isFinite(width) && width > KEY_MAX_WIDTH) return false;
264
+ const label = String(t?.label ?? '').trim();
265
+ if (!label) return true;
266
+ if (label.length <= 2) return true;
267
+ return NAMED_KEY.test(label);
268
+ }
269
+
270
+ /**
271
+ * Where the software keyboard starts, or null.
272
+ *
273
+ * Size and uniformity alone were not enough, and the failure was expensive. A
274
+ * read-only summary screen stacks a dozen short text rows of near-identical
275
+ * height in the bottom half — which satisfied every test here, so a keyboard
276
+ * was detected on a screen that had none.
277
+ *
278
+ * That mattered far beyond a mislabelled band, because `fingerprint.tokens`
279
+ * discards everything below `keyboardTop`. A phantom keyboard therefore
280
+ * deleted the screen's entire content from its own identity, leaving only
281
+ * chrome — so a wizard's form step and its read-only review screen, which
282
+ * share a nav title and a step indicator, **collapsed onto one hash**. From
283
+ * there: the graph offered one screen's remembered controls on the other (three
284
+ * absent controls, one of them beside a button that submits for real), and
285
+ * `locate` resolved against the wrong screen's stored element list, which is
286
+ * why `assert` insisted a string was absent while the map printed it four lines
287
+ * below. One phantom, three findings.
288
+ *
289
+ * So the test is now what a keyboard actually is: keys. A dozen small uniform
290
+ * boxes are a keyboard only if most of them are key-shaped.
291
+ */
246
292
  export function detectKeyboardTop(elements, screen) {
247
293
  if (!screen?.height || elements.length < 12) return null;
248
294
  const threshold = screen.height * (1 - KEYBOARD_MIN_FRACTION);
@@ -253,7 +299,70 @@ export function detectKeyboardTop(elements, screen) {
253
299
  // Keys are small and uniform; a list of cells down there is not.
254
300
  const uniform = heights.filter((h) => Math.abs(h - median) <= Math.max(3, median * 0.4)).length;
255
301
  if (uniform / low.length < 0.7 || median > screen.height * 0.07) return null;
256
- return Math.min(...low.map((e) => e.frame.y));
302
+ // And they are keys. Uniformity says "a grid of something"; this says of what.
303
+ const keyish = low.filter(looksLikeKey).length;
304
+ if (keyish / low.length < KEYBOARD_MIN_KEYISH) return null;
305
+ return extendKeyboardUp(elements, Math.min(...low.map((e) => e.frame.y)), median);
306
+ }
307
+
308
+ /**
309
+ * Walk the boundary up through rows that are still keys.
310
+ *
311
+ * `KEYBOARD_MIN_FRACTION` is a **detection window**, not the keyboard's height,
312
+ * and using its edge as the boundary cut the keyboard's own top row off.
313
+ * Measured on a recorded iPhone 17 Pro screen with the software keyboard up: the
314
+ * window starts at y=629, the `q`–`p` row's frame top is **590**, so that entire
315
+ * row was excluded and the boundary landed on the `a` row at 644 — ten keys
316
+ * reported as page content, in the same map that said `keyboard up`.
317
+ *
318
+ * Widening the window instead would be the wrong fix: 0.28 of the screen is
319
+ * deliberately conservative so a list of short rows at the bottom of a page
320
+ * cannot be mistaken for a keyboard, and a real keyboard is nearer 0.38. So the
321
+ * window still *decides*, and this extends the boundary only while the rows
322
+ * above keep being key-shaped — which page content is not.
323
+ *
324
+ * The concrete cost of not having this: a sweep gesture aimed 8pt above the
325
+ * boundary still landed on the top row of keys and scrolled nothing.
326
+ */
327
+ function extendKeyboardUp(elements, top, median) {
328
+ let boundary = top;
329
+ // Four rows is a full keyboard's worth; the loop stops on its own long before
330
+ // that on anything that is not one.
331
+ for (let i = 0; i < 4; i += 1) {
332
+ const row = (elements ?? []).filter((e) => e.frame
333
+ && looksLikeKey(e)
334
+ && Math.abs(heightOf(e.frame) - median) <= Math.max(3, median * 0.4)
335
+ // Sitting directly on the current boundary, within one row's height.
336
+ && e.frame.y + heightOf(e.frame) <= boundary + 4
337
+ && e.frame.y + heightOf(e.frame) >= boundary - median * 1.6);
338
+ if (row.length < 5) break;
339
+ const next = Math.min(...row.map((e) => e.frame.y));
340
+ if (!(next < boundary)) break;
341
+ boundary = next;
342
+ }
343
+ return boundary;
344
+ }
345
+
346
+ /**
347
+ * Is this element outside the viewport?
348
+ *
349
+ * **Both axes.** Every filter in this project checked `y` and ignored `x`,
350
+ * which is fine until a horizontal row: a filter chip reported at **x=422 on a
351
+ * 402pt-wide screen** counted as visible, and `scroll_to` then said *"'Assigned
352
+ * to Me' is in view at 422,277 already"* — confidently wrong about the one thing
353
+ * it exists to answer. Off-screen chips came back at **x=-247** the same way.
354
+ *
355
+ * Reported as the most expensive finding of an agent's session, and the cost was
356
+ * not the wrong answer itself: it was that the wrong answer was *confident*, so
357
+ * the recovery was hand-tuned swipes and two overshoots.
358
+ */
359
+ export function offViewport(t, screen) {
360
+ if (!t) return false;
361
+ const w = screen?.width;
362
+ const h = screen?.height;
363
+ if (Number.isFinite(h) && (t.y < 0 || t.y > h)) return true;
364
+ if (Number.isFinite(w) && (t.x < 0 || t.x > w)) return true;
365
+ return false;
257
366
  }
258
367
 
259
368
  /** Annotate a target list with region and nav slot. Mutates and returns it. */