simframe 0.4.2 → 0.6.0-rc.1

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 (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
package/src/screenmap.js CHANGED
@@ -8,11 +8,14 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { hashDistance } from './analyze.js';
11
+ import * as control from './control.js';
12
+ import * as fingerprint from './fingerprint.js';
11
13
  import * as input from './input.js';
12
14
  import * as ocr from './ocr.js';
15
+ import * as regions from './regions.js';
13
16
  import * as store from './store.js';
14
17
 
15
- const MAP_VERSION = 2; // layout hash crop changed; old maps no longer comparable
18
+ const MAP_VERSION = 6; // dates, prices and phone numbers no longer contribute labels
16
19
 
17
20
  function mapDir(udid) {
18
21
  return path.join(store.deviceDir(udid), 'screens');
@@ -20,11 +23,18 @@ function mapDir(udid) {
20
23
 
21
24
  /**
22
25
  * How many of the 288 layout bits may differ and still count as the same screen.
23
- * Measured on a real app: revisiting a screen (with different list rows and a
24
- * different clock) moved 0-3 bits; different screens were 77-96 apart. 12 sits
25
- * well clear of both.
26
+ *
27
+ * Re-measured across four visits to each of five screens: a revisit is usually
28
+ * identical (median 0) but the tail reaches 62 when list content has changed,
29
+ * while different screens sit at 74 and above. That margin is much narrower
30
+ * than the first calibration suggested, and it is the reason this number stays
31
+ * conservative rather than being raised to cover the tail.
32
+ *
33
+ * The consequence is deliberate: a heavily changed screen is rebuilt rather
34
+ * than recognised. A rebuild costs ~300ms; a false match taps the wrong
35
+ * control. See docs/DEFERRED.md on fingerprinting structure instead of pixels.
26
36
  */
27
- export const DEFAULT_TOLERANCE = 12;
37
+ export const DEFAULT_TOLERANCE = 20;
28
38
 
29
39
  export function recall(udid, hash) {
30
40
  if (!hash) return null;
@@ -104,16 +114,47 @@ export async function build(udid, {
104
114
  density = 3,
105
115
  useAx = true,
106
116
  useOcr = true,
117
+ persist = true,
107
118
  screen,
108
119
  } = {}) {
109
120
  const targets = [];
110
121
  const sources = [];
111
- // Kick OCR off before reading the tree: they are independent, and the OCR
112
- // pass is pure computation on a file that already exists.
113
- const ocrPromise =
114
- useOcr && fullFrame && fs.existsSync(fullFrame)
122
+ // Why a layer is missing, kept rather than swallowed.
123
+ //
124
+ // A partial map used to be indistinguishable from a whole one: `sources` said
125
+ // ["ax"] and nothing said why OCR was not there. On CI this produced a map
126
+ // with zero elements reported as a successful read, and the only way to find
127
+ // out what had happened was to guess. Before the tree came in-process the
128
+ // same failure was loud, because with no idb the ax layer failed too and an
129
+ // empty `sources` rethrew — so making a layer work turned a loud failure into
130
+ // a quiet one.
131
+ const degraded = [];
132
+ // One round trip for both, because the daemon runs the tree read and the
133
+ // recognition pass concurrently against the same instant of the screen. Asked
134
+ // separately they would queue: the control socket serves one request at a
135
+ // time, so a second call pays the first one's latency before it starts.
136
+ //
137
+ // The daemon reads text straight off the framebuffer. The fallback encodes a
138
+ // PNG, writes it, spawns a helper and decodes it again — measured at 555ms
139
+ // against 174ms — so it is only used when no daemon is listening.
140
+ const viaDaemon = (useOcr || useAx) && control.available(udid);
141
+ const axViaDaemon = useAx && process.env.SIMFRAME_AX_DRIVER !== 'idb';
142
+ const daemonPromise = viaDaemon
143
+ ? control.request(udid, { action: 'ui', ax: axViaDaemon, ocr: useOcr }).catch((err) => err)
144
+ : null;
145
+ const ocrPromise = !useOcr || viaDaemon
146
+ ? null
147
+ : fullFrame && fs.existsSync(fullFrame)
115
148
  ? ocr.readText(fullFrame, { density }).catch((err) => err)
116
149
  : null;
150
+ const daemonAnswer = daemonPromise ? await daemonPromise : null;
151
+ // Keep the failure rather than flattening it to null. A daemon that was
152
+ // listening and then did not answer is a loud failure, and the version of
153
+ // this that dropped it returned an empty map with no error — which `persist`
154
+ // then wrote into screen memory, so a later warm visit read the emptiness
155
+ // back instead of perceiving the screen again.
156
+ const daemonError = daemonAnswer instanceof Error ? daemonAnswer : null;
157
+ const daemonScreen = daemonError ? null : daemonAnswer?.screen ?? null;
117
158
  // With no geometry, treat every element as a potential control rather than
118
159
  // guessing a screen size and mis-classifying containers.
119
160
  const screenArea = screen?.width && screen?.height ? screen.width * screen.height : Infinity;
@@ -125,7 +166,12 @@ export async function build(udid, {
125
166
 
126
167
  if (useAx) {
127
168
  try {
128
- const nodes = await input.describeAll(udid);
169
+ // The tree is already in hand when the daemon answered; describeAll would
170
+ // only ask for it a second time.
171
+ const nodes = daemonScreen?.sources?.includes('ax')
172
+ ? daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode)
173
+ : await input.describeAll(udid);
174
+
129
175
  sources.push('ax');
130
176
  for (const n of nodes) {
131
177
  if (!n.frame || !n.label || isContainer(n)) continue;
@@ -139,15 +185,40 @@ export async function build(udid, {
139
185
  source: 'ax',
140
186
  });
141
187
  }
142
- } catch {
188
+ } catch (err) {
143
189
  /* no idb, or the tree read failed; OCR alone is still useful */
190
+ degraded.push(`accessibility: ${err.message}`);
144
191
  }
145
192
  }
146
193
 
147
- if (ocrPromise) {
194
+ // Neither layer was even attempted: nothing below can report the failure, so
195
+ // it has to be reported here rather than returned as an empty screen.
196
+ if (daemonError && !useOcr) throw daemonError;
197
+
198
+ if (ocrPromise || (useOcr && viaDaemon)) {
148
199
  try {
149
- const words = await ocrPromise;
150
- if (words instanceof Error) throw words;
200
+ const result = ocrPromise ? await ocrPromise : (daemonError ?? daemonScreen);
201
+ if (result instanceof Error) throw result;
202
+ if (!result) throw new Error('the daemon did not answer');
203
+ // A daemon that answered without reading text is not an OCR source, and
204
+ // saying it was would claim the screen had been read when it had not.
205
+ if (viaDaemon && !result.sources?.includes('ocr')) throw new Error(result.ocrError ?? 'no text was read');
206
+ // The daemon answers in points; readText answers in points too, having
207
+ // divided by density. Normalise the daemon's element shape to match.
208
+ const words = viaDaemon
209
+ ? (result.elements ?? [])
210
+ .filter((e) => e.source?.includes('ocr') && e.label?.trim())
211
+ .map((e) => ({
212
+ text: e.label,
213
+ confidence: e.confidence,
214
+ x: e.frame.x,
215
+ y: e.frame.y,
216
+ width: e.frame.width,
217
+ height: e.frame.height,
218
+ centerX: e.center.x,
219
+ centerY: e.center.y,
220
+ }))
221
+ : result;
151
222
  sources.push('ocr');
152
223
  for (const w of words) {
153
224
  if (!w.text.trim()) continue;
@@ -183,18 +254,44 @@ export async function build(udid, {
183
254
  });
184
255
  }
185
256
  } catch (err) {
257
+ degraded.push(`text recognition: ${err.message}`);
186
258
  if (!sources.length) throw err;
187
259
  }
188
260
  }
189
261
 
190
- return remember(udid, {
191
- version: MAP_VERSION,
192
- hash,
193
- layoutHash,
194
- at: Date.now(),
195
- sources,
196
- targets,
197
- });
262
+ return finish();
263
+
264
+ function finish() {
265
+ // Region priors are geometry, so they cost nothing and disambiguate a great
266
+ // deal: "Assets" the nav title and "Assets" the tab differ only by where
267
+ // they are.
268
+ if (screen?.width && screen?.height) regions.annotate(targets, screen);
269
+ // Two hashes, two jobs. The pixel layout hash indexes this entry, because
270
+ // it can be computed from a frame alone and so can find a map without
271
+ // building one. The structural hash identifies the screen, because content
272
+ // is pixels and a list with new rows is not a new screen.
273
+ const structure = screen?.width && screen?.height
274
+ ? fingerprint.fingerprint(targets, screen)
275
+ : { hash: null, tokens: [], keyboard: false };
276
+ const entry = {
277
+ version: MAP_VERSION,
278
+ hash,
279
+ layoutHash,
280
+ structuralHash: structure.hash,
281
+ structuralTokens: structure.tokens,
282
+ keyboard: structure.keyboard,
283
+ at: Date.now(),
284
+ sources,
285
+ targets,
286
+ };
287
+ // A truncated tree is nodes without authority; the daemon says so and the
288
+ // map has to carry it, because this is what gets written into memory.
289
+ if (daemonScreen?.axTruncated) degraded.push(`accessibility tree cut short: ${daemonScreen.axTruncated}`);
290
+ if (degraded.length) entry.degraded = degraded;
291
+ // Only a map of a settled screen is worth keeping; remembering a transition
292
+ // fills the store with layouts that will never be seen again.
293
+ return persist ? remember(udid, entry) : entry;
294
+ }
198
295
  }
199
296
 
200
297
  const norm = (s) => String(s ?? '').toLowerCase().trim();
package/src/simctl.js CHANGED
@@ -96,9 +96,17 @@ export function isBootedSync(udid) {
96
96
  }
97
97
 
98
98
  export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
99
- await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
100
- timeout: 10_000,
101
- });
99
+ try {
100
+ await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
101
+ timeout: 10_000,
102
+ });
103
+ } catch (err) {
104
+ // Same reason as launchApp: execFile's message is "Command failed: <the
105
+ // whole command>" and simctl's actual complaint is in stderr. A CI failure
106
+ // here reported the command and nothing about why it did not work.
107
+ const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
108
+ throw new Error(detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`);
109
+ }
102
110
  }
103
111
 
104
112
  /** Resample with sips, which ships with macOS, so simframe needs no image deps. */
@@ -106,8 +114,38 @@ export async function resize(inFile, outFile, maxDim) {
106
114
  await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
107
115
  }
108
116
 
109
- export async function launchApp(udid, bundleId) {
110
- await run('xcrun', ['simctl', 'launch', udid, bundleId], { timeout: 20_000 });
117
+ /**
118
+ * Launch, optionally with arguments and environment.
119
+ *
120
+ * simctl passes launch arguments after the bundle id and environment through
121
+ * `SIMCTL_CHILD_`-prefixed variables of its own process — which is why env has
122
+ * to be set on the child rather than passed as flags.
123
+ */
124
+ export async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst = false } = {}) {
125
+ if (terminateFirst) {
126
+ // A launch against an already-running app is a no-op that reports success,
127
+ // which is how a flow "relaunched" an app and tested the screen it was
128
+ // already on.
129
+ try {
130
+ await terminateApp(udid, bundleId);
131
+ } catch {
132
+ /* not running; that is the state we wanted */
133
+ }
134
+ }
135
+ const childEnv = { ...process.env };
136
+ for (const [k, v] of Object.entries(env)) childEnv[`SIMCTL_CHILD_${k}`] = String(v);
137
+ try {
138
+ await run('xcrun', ['simctl', 'launch', udid, bundleId, ...args.map(String)], {
139
+ timeout: 20_000,
140
+ env: childEnv,
141
+ });
142
+ } catch (err) {
143
+ // execFile's message is just "Command failed: ..." with simctl's actual
144
+ // complaint left in stderr. A CI run failed here and said nothing about
145
+ // why, which is the same sin as a silent fallback.
146
+ const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
147
+ throw new Error(detail ? `could not launch ${bundleId}: ${detail}` : `could not launch ${bundleId}: ${err.message}`);
148
+ }
111
149
  }
112
150
 
113
151
  export async function terminateApp(udid, bundleId) {
@@ -118,6 +156,37 @@ export async function openUrl(udid, url) {
118
156
  await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: 20_000 });
119
157
  }
120
158
 
159
+ export const PERMISSION_SERVICES = [
160
+ 'all', 'calendar', 'contacts-limited', 'contacts', 'location', 'location-always',
161
+ 'photos-add', 'photos', 'media-library', 'microphone', 'motion', 'reminders', 'siri',
162
+ ];
163
+
164
+ /**
165
+ * Grant, revoke or reset a privacy permission.
166
+ *
167
+ * The point of doing this from a test harness is that the alternative is
168
+ * tapping a system alert, and a system alert is not part of the app under test:
169
+ * its buttons move between iOS versions and its appearance is a race.
170
+ */
171
+ export async function setPermission(udid, action, service, bundleId) {
172
+ const verb = String(action).toLowerCase();
173
+ if (!['grant', 'revoke', 'reset'].includes(verb)) {
174
+ throw new Error(`permission action must be grant, revoke or reset (got "${action}")`);
175
+ }
176
+ if (!PERMISSION_SERVICES.includes(service)) {
177
+ throw new Error(`unknown permission "${service}" — one of: ${PERMISSION_SERVICES.join(', ')}`);
178
+ }
179
+ const args = ['simctl', 'privacy', udid, verb, service];
180
+ if (bundleId) args.push(bundleId);
181
+ try {
182
+ await run('xcrun', args, { timeout: 20_000 });
183
+ } catch (err) {
184
+ const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
185
+ throw new Error(`could not ${verb} ${service}: ${detail || err.message}`);
186
+ }
187
+ return `${verb === 'reset' ? 'reset' : verb + 'ed'} ${service}${bundleId ? ` for ${bundleId}` : ''}`;
188
+ }
189
+
121
190
  /** Put text on the device pasteboard — far faster than typing a long string. */
122
191
  export async function setPasteboard(udid, value) {
123
192
  const child = execFile('xcrun', ['simctl', 'pbcopy', udid], { timeout: 10_000 });
package/src/store.js CHANGED
@@ -5,6 +5,14 @@ import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
 
8
+ /**
9
+ * A directory under ROOT is a device only if it is named like a UDID.
10
+ *
11
+ * `simframe status` listed five phantom `? ` rows once, which were test
12
+ * fixtures. Anything that is not a UDID is not a device, whoever wrote it.
13
+ */
14
+ export const isUdid = (name) => /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(name);
15
+
8
16
  export const ROOT = process.env.SIMFRAME_HOME || path.join(os.homedir(), '.simframe');
9
17
 
10
18
  export function deviceDir(udid) {
package/src/view.js ADDED
@@ -0,0 +1,342 @@
1
+ // What Claude sees.
2
+ //
3
+ // Every action used to answer with an image. An image costs 1,600 tokens when
4
+ // Claude Code handles it natively and 15,000–25,000 when it does not, and it
5
+ // answers the one question the tool already knew: what is on the screen and
6
+ // what can be tapped. This module answers that in text, with a number in front
7
+ // of every element so the next call can name one without describing it.
8
+ //
9
+ // The format is deliberately dense. Region first, because "Assets" the nav
10
+ // title and "Assets" the tab differ only by where they are; a tap point,
11
+ // because that is what an action needs; and the source, because an element the
12
+ // app published and one OCR read off the pixels deserve different amounts of
13
+ // trust.
14
+ import * as api from './index.js';
15
+ import * as graph from './graph.js';
16
+ import { writeRefs } from './refs.js';
17
+
18
+ /** Reading order. Chrome frames the screen, so it reads first and last. */
19
+ const REGION_ORDER = ['nav-bar', 'content', 'tab-bar', 'keyboard', 'status-bar'];
20
+
21
+ /**
22
+ * The status bar says the time and the battery level. It is on every screen,
23
+ * it is never what anybody wants to tap, and it costs a row every time.
24
+ */
25
+ const HIDDEN_REGIONS = new Set(['status-bar']);
26
+
27
+ /** A keyboard is 30-odd keys nobody refers to by name. One line says it. */
28
+ const COLLAPSE_REGIONS = new Set(['keyboard']);
29
+
30
+ /** Past this many rows the map stops being cheaper than looking. */
31
+ export const DEFAULT_LIMIT = 60;
32
+
33
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
34
+
35
+ /** Types that are hit targets rather than description. */
36
+ const INTERACTIVE = /button|field|cell|link|switch|slider|tab|menu|segment|checkbox/i;
37
+
38
+ /** A label past this length is a paragraph, and no selector needs a paragraph. */
39
+ const MAX_LABEL = 64;
40
+
41
+ /** Anything covering more of the screen than this is scenery, not a control. */
42
+ const CONTAINER_AREA_FRACTION = 0.35;
43
+
44
+ const area = (f) => (f ? Math.max(1, f.width) * Math.max(1, f.height) : 0);
45
+
46
+ const centerInside = (t, f) =>
47
+ Boolean(f) && t.x >= f.x && t.x <= f.x + f.width && t.y >= f.y && t.y <= f.y + f.height;
48
+
49
+ /**
50
+ * Fold read text into the control it is printed on.
51
+ *
52
+ * The screen map keeps an accessibility element and the text OCR read off it as
53
+ * separate targets, deliberately: identity is computed from that list and
54
+ * throwing away a target would change what a screen is. But as something to
55
+ * show a model it is nearly twice as long as it needs to be — "TRACK TIME" the
56
+ * button and "TRACK TIME" the pixels are one thing to tap.
57
+ *
58
+ * So the folding happens here, in the presentation, and the fingerprint never
59
+ * sees it. The rule is containment plus interactivity: text sitting inside a
60
+ * button belongs to that button. Size is not part of it — that check exists in
61
+ * screenmap.build to stop a tab bar swallowing its five tabs, and a tab bar is
62
+ * not interactive.
63
+ */
64
+ /**
65
+ * How many pieces of text one control may absorb.
66
+ *
67
+ * A control's visible text is a fragment or three: a title, a count, a unit. A
68
+ * container swallows five, and a tab bar that absorbed its own tabs would leave
69
+ * nothing to tap. This is what separates the two, because size does not: a
70
+ * dashboard tile and a tab bar are the same few thousand square points.
71
+ */
72
+ const MAX_ABSORBED = 3;
73
+
74
+ /** Could this be the thing the text is printed on? */
75
+ function isHost(t) {
76
+ if (!t.frame) return false;
77
+ if (INTERACTIVE.test(t.type || '')) return true;
78
+ // An accessibility element the app gave a label to is a unit the app itself
79
+ // considers one thing — a dashboard tile reading "WOs past ETA, 1910" is one
80
+ // tap target whose parts OCR happens to read separately.
81
+ return t.source === 'ax' && Boolean(t.label);
82
+ }
83
+
84
+ /**
85
+ * Fold read text into the control it is printed on.
86
+ *
87
+ * The screen map keeps an accessibility element and the text OCR read off it as
88
+ * separate targets, deliberately: identity is computed from that list and
89
+ * dropping a target would change what a screen is. But as something to show a
90
+ * model it is nearly twice as long as it needs to be — "TRACK TIME" the button
91
+ * and "TRACK TIME" the pixels are one thing to tap.
92
+ *
93
+ * So the folding happens here, in the presentation, and the fingerprint never
94
+ * sees it.
95
+ */
96
+ function foldText(targets) {
97
+ const hosts = targets.filter(isHost);
98
+ // Who would absorb what, before absorbing anything: a host that turns out to
99
+ // be a container must not have already eaten two of its children.
100
+ const claims = new Map(hosts.map((h) => [h, []]));
101
+ for (const t of targets) {
102
+ if (isHost(t)) continue;
103
+ const host = hosts
104
+ .filter((h) => centerInside(t, h.frame))
105
+ .sort((a, b) => area(a.frame) - area(b.frame))[0];
106
+ if (host) claims.get(host).push(t);
107
+ }
108
+
109
+ const absorbed = new Set();
110
+ for (const [host, texts] of claims) {
111
+ if (!texts.length || texts.length > MAX_ABSORBED) continue;
112
+ for (const t of texts) {
113
+ absorbed.add(t);
114
+ const text = String(t.label ?? '').trim();
115
+ if (text && !saysTheSame(host, text)) host.aliases = [...(host.aliases ?? []), text];
116
+ }
117
+ }
118
+ return targets.filter((t) => !absorbed.has(t));
119
+ }
120
+
121
+ /** Comparison that ignores what OCR adds: a stray bullet, a mangled glyph. */
122
+ const alnum = (s_) => String(s_ ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
123
+
124
+ function saysTheSame(host, text) {
125
+ const t = alnum(text);
126
+ if (!t) return true;
127
+ return [host.label, ...(host.aliases ?? [])]
128
+ .filter(Boolean)
129
+ .some((known) => alnum(known).includes(t));
130
+ }
131
+
132
+ /**
133
+ * Drop the scenery.
134
+ *
135
+ * A group that encloses several other elements is the thing they are arranged
136
+ * in, not a thing anybody means to tap — and it is exactly what "tap the tab
137
+ * bar" would resolve to if it were listed.
138
+ */
139
+ function dropContainers(targets, screen) {
140
+ const screenArea = screen?.width && screen?.height ? screen.width * screen.height : Infinity;
141
+ return targets.filter((t) => {
142
+ if (!t.frame) return true;
143
+ if (INTERACTIVE.test(t.type || '')) return true;
144
+ if (area(t.frame) > screenArea * CONTAINER_AREA_FRACTION) return false;
145
+ const encloses = targets.filter((o) => o !== t && centerInside(o, t.frame)).length;
146
+ return encloses < 2;
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Text with no letters or digits in it is OCR reading the furniture: a divider,
152
+ * an ellipsis menu, a chevron it decided was a period. Nothing can be tapped by
153
+ * that name, so listing it is pure cost.
154
+ */
155
+ const isNoise = (t) => t.source === 'ocr' && !alnum(t.label);
156
+
157
+ const trim = (text) => {
158
+ const one = String(text ?? '').replace(/\s+/g, ' ').trim();
159
+ return one.length > MAX_LABEL ? `${one.slice(0, MAX_LABEL - 1)}…` : one;
160
+ };
161
+
162
+ /** Rank and number what is on screen. */
163
+ export function rowsFor(entry, { screen, filter, interactive, all = false, limit = DEFAULT_LIMIT } = {}) {
164
+ let kept = (entry?.targets ?? []).map((t) => ({ ...t })).filter((t) => {
165
+ if (!isNum(t.x) || !isNum(t.y)) return false;
166
+ // Off-screen elements are real in the tree and untappable in fact.
167
+ if (screen?.height && (t.y < 0 || t.y > screen.height)) return false;
168
+ if (!all && HIDDEN_REGIONS.has(t.region)) return false;
169
+ if (!all && isNoise(t)) return false;
170
+ return true;
171
+ });
172
+
173
+ if (!all) {
174
+ kept = foldText(kept);
175
+ kept = dropContainers(kept, screen);
176
+ }
177
+
178
+ if (filter) {
179
+ const q = String(filter).toLowerCase();
180
+ kept = kept.filter((t) =>
181
+ [t.label, ...(t.aliases ?? [])].filter(Boolean).join(' ').toLowerCase().includes(q));
182
+ }
183
+ if (interactive) kept = kept.filter((t) => INTERACTIVE.test(t.type || ''));
184
+
185
+ const order = (t) => {
186
+ const i = REGION_ORDER.indexOf(t.region ?? 'content');
187
+ return i === -1 ? REGION_ORDER.indexOf('content') : i;
188
+ };
189
+ kept.sort((a, b) => order(a) - order(b) || a.y - b.y || a.x - b.x);
190
+
191
+ const rows = [];
192
+ const collapsed = new Map();
193
+ for (const t of kept) {
194
+ const region = t.region ?? 'content';
195
+ if (COLLAPSE_REGIONS.has(region)) {
196
+ collapsed.set(region, (collapsed.get(region) ?? 0) + 1);
197
+ continue;
198
+ }
199
+ rows.push({ ...t, region, ref: rows.length + 1 });
200
+ }
201
+ return { rows: rows.slice(0, limit), truncated: Math.max(0, rows.length - limit), collapsed };
202
+ }
203
+
204
+ function renderRow(r) {
205
+ const name = [
206
+ trim(r.label) || (r.source === 'ax' ? '(unlabelled)' : '(no text)'),
207
+ aliasNote(r),
208
+ ].filter(Boolean).join(' ');
209
+ const state = [
210
+ r.enabled === false ? 'disabled' : null,
211
+ r.selected ? 'selected' : null,
212
+ ].filter(Boolean).join(',');
213
+ return [
214
+ `#${r.ref}`.padStart(4),
215
+ shortType(r.type).padEnd(9),
216
+ `${r.x},${r.y}`.padEnd(9),
217
+ state ? `${state} ` : '',
218
+ name,
219
+ ].join(' ');
220
+ }
221
+
222
+ /**
223
+ * Only aliases that say something the label does not.
224
+ *
225
+ * The screen map records OCR's reading of an element it already had a label
226
+ * for, which is useful when they disagree and pure cost when they agree —
227
+ * "WELCOME ~ WELCOME" was a third of some rows.
228
+ */
229
+ function aliasNote(r) {
230
+ const extra = (r.aliases ?? [])
231
+ .filter((a) => {
232
+ const t = alnum(a);
233
+ return t && !alnum(r.label).includes(t);
234
+ })
235
+ .slice(0, 2);
236
+ return extra.length ? `~ ${trim(extra.join(' '))}` : null;
237
+ }
238
+
239
+ /**
240
+ * Element types, in as few characters as carry the meaning. iOS calls things
241
+ * `GenericElement` and `StaticText`; nothing is lost by calling them `element`
242
+ * and `text`, and a column of them costs a third as much.
243
+ */
244
+ const TYPE_NAMES = [
245
+ [/textfield|textview|searchfield|field/i, 'field'],
246
+ [/button/i, 'button'],
247
+ [/statictext|^text$/i, 'text'],
248
+ [/cell|row/i, 'cell'],
249
+ [/^link$/i, 'link'],
250
+ [/switch|toggle/i, 'switch'],
251
+ [/tab/i, 'tab'],
252
+ [/image|icon/i, 'image'],
253
+ [/generic|other|group|^any$/i, 'element'],
254
+ ];
255
+
256
+ function shortType(type) {
257
+ const t = String(type ?? '').trim();
258
+ if (!t) return '?';
259
+ for (const [pattern, name] of TYPE_NAMES) if (pattern.test(t)) return name;
260
+ return t.slice(0, 9).toLowerCase();
261
+ }
262
+
263
+ /**
264
+ * The whole map as text.
265
+ *
266
+ * One read of the screen produces the identity, the elements and the verdict,
267
+ * so this is the same cost as the `screenIdentity` call an action already makes
268
+ * to verify itself.
269
+ */
270
+ export async function screenMap(deviceQuery, {
271
+ options,
272
+ filter,
273
+ interactive,
274
+ all = false,
275
+ limit = DEFAULT_LIMIT,
276
+ refresh = false,
277
+ identity: given,
278
+ } = {}) {
279
+ // `refresh` rebuilds this screen's map; it does not wipe the device's memory.
280
+ // Forgetting everything to re-read one screen would throw away every other
281
+ // screen's muscle memory to answer a question about this one.
282
+ const identity = given ?? await api.screenIdentity(deviceQuery, { options, confirmNovel: false, fresh: refresh });
283
+ const { device } = await api.ensureDaemon(deviceQuery, options);
284
+ const udid = device.udid;
285
+ const entry = identity.entry;
286
+ const screen = identity.points;
287
+
288
+ const { rows, truncated, collapsed } = rowsFor(entry, { screen, filter, interactive, all, limit });
289
+ writeRefs(udid, { structuralHash: identity.hash, layoutHash: identity.layoutHash, rows });
290
+
291
+ const found = identity.hash ? graph.nearestScreen(udid, identity) : null;
292
+ const node = found?.node ?? null;
293
+ const name = node ? graph.describe(node) : null;
294
+ // Not a visit count — nothing stores one. The number of edges out of this
295
+ // screen is what the agent can actually use: it says how much of this screen
296
+ // the graph can navigate from without being told.
297
+ const exits = node ? node.edges.length : null;
298
+
299
+ return {
300
+ device,
301
+ identity,
302
+ rows,
303
+ truncated,
304
+ collapsed,
305
+ screen,
306
+ name,
307
+ exits,
308
+ text: render({ device, identity, rows, truncated, collapsed, screen, name, exits }),
309
+ };
310
+ }
311
+
312
+ export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, verdictLine, ambiguities }) {
313
+ const head = [
314
+ device?.name,
315
+ screen?.width ? `${screen.width}x${screen.height}pt` : null,
316
+ identity?.hash
317
+ ? `screen ${identity.hash.slice(0, 8)}${name ? ` "${name}"` : ''}` +
318
+ (exits == null ? ' (new to simframe)' : ` (known, ${exits} known exit${exits === 1 ? '' : 's'})`)
319
+ : 'screen unidentified',
320
+ identity?.keyboard ? 'keyboard up' : null,
321
+ identity?.settled === false ? 'STILL MOVING' : null,
322
+ ].filter(Boolean).join(' · ');
323
+
324
+ const lines = [head];
325
+ if (verdictLine) lines.push(verdictLine);
326
+
327
+ let region = null;
328
+ for (const r of rows) {
329
+ if (r.region !== region) {
330
+ region = r.region;
331
+ lines.push(`${region}:`);
332
+ }
333
+ lines.push(renderRow(r));
334
+ }
335
+ for (const [name_, count] of collapsed ?? []) lines.push(`${name_}: ${count} keys (tap by label or type directly)`);
336
+ if (!rows.length) lines.push('no elements read on this screen — try sim_look, or the app may still be drawing');
337
+ if (truncated) lines.push(`... ${truncated} more; pass filter to narrow`);
338
+ if (ambiguities?.length) {
339
+ for (const a of ambiguities) lines.push(`ambiguous "${a.query}": ${a.options.join(', ')}`);
340
+ }
341
+ return lines.join('\n');
342
+ }