simframe 0.5.0 → 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.
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ import * as input from './input.js';
17
17
  import * as fingerprint from './fingerprint.js';
18
18
  import * as graph from './graph.js';
19
19
  import * as matching from './matching.js';
20
+ import * as refs from './refs.js';
20
21
  import * as screenmap from './screenmap.js';
21
22
  import { resolveDevice, resize, screenshot } from './simctl.js';
22
23
  import * as store from './store.js';
@@ -25,7 +26,24 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
26
  const CLI = path.join(HERE, 'cli.js');
26
27
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
28
 
28
- export const DETAIL_LEVELS = { low: 420, normal: 700, high: 1100, full: 0 };
29
+ export const DETAIL_LEVELS = { low: 420, normal: 700, high: 1024, full: 0 };
30
+
31
+ /**
32
+ * The ceiling on any image handed to a model.
33
+ *
34
+ * An image costs ~1,600 tokens when Claude Code handles it as a native image
35
+ * block, and 15,000–25,000 when the base64 is treated as text (claude-code
36
+ * issue #31208) — enough to trip the 25,000-token tool-result limit on its own.
37
+ * A native-resolution frame buys nothing at either price: 1024 px on the long
38
+ * edge is already more than a 393-point screen has to say. Only the CLI, which
39
+ * writes to a file rather than into a context window, may exceed it.
40
+ */
41
+ export const MODEL_MAX_IMAGE_DIM = 1024;
42
+
43
+ export function modelDetail(detail) {
44
+ const dim = resolveMaxDim(detail);
45
+ return dim === 0 || dim > MODEL_MAX_IMAGE_DIM ? MODEL_MAX_IMAGE_DIM : dim;
46
+ }
29
47
 
30
48
  export function resolveMaxDim(detail) {
31
49
  if (typeof detail === 'number') return detail;
@@ -713,6 +731,40 @@ export async function locate(
713
731
  ) {
714
732
  const { device, state: firstState } = await ensureDaemon(deviceQuery, options);
715
733
  const udid = device.udid;
734
+
735
+ // Selectors resolve before any perception happens: `#3` is already an answer
736
+ // somebody numbered, and `@x,y` was never a question about the screen.
737
+ const selector = refs.parseSelector(query);
738
+ if (selector.kind === 'point') {
739
+ return {
740
+ device,
741
+ state: firstState,
742
+ target: { label: `(${selector.x},${selector.y})`, x: selector.x, y: selector.y, source: 'coordinates' },
743
+ from: 'selector',
744
+ distance: 0,
745
+ settled: true,
746
+ };
747
+ }
748
+ if (selector.kind === 'ref') {
749
+ // Screen memory answers "which screen is this?" from a file, so a ref can
750
+ // be checked against structural identity without paying for a perception
751
+ // pass — which is the whole reason a ref exists.
752
+ const near = screenmap.recallNearest(udid, firstState.layoutHash);
753
+ const hit = refs.resolveRef(udid, selector.ref, {
754
+ layoutHash: firstState.layoutHash,
755
+ structuralHash: near?.entry?.structuralHash ?? null,
756
+ screenKnown: Boolean(near),
757
+ });
758
+ return {
759
+ device,
760
+ state: firstState,
761
+ target: { ...hit, label: hit.label ?? `#${hit.ref}`, source: hit.source ?? 'ref' },
762
+ from: 'ref',
763
+ distance: 0,
764
+ settled: true,
765
+ };
766
+ }
767
+ if (selector.exact) query = selector.label;
716
768
  // Key memory off a settled frame, never off whichever frame happened to be
717
769
  // newest, so the capture rate cannot change what gets remembered.
718
770
  const { state, settled } = await settledState(udid, { settleMs });
@@ -839,7 +891,7 @@ const STRUCTURAL_SETTLE_SAMPLES = 3;
839
891
  * to. Novel fingerprints, and only those, are re-sampled until two consecutive
840
892
  * readings agree.
841
893
  */
842
- export async function screenIdentity(deviceQuery, { options, confirmNovel = true, settleMs, timeoutMs } = {}) {
894
+ export async function screenIdentity(deviceQuery, { options, confirmNovel = true, settleMs, timeoutMs, fresh = false } = {}) {
843
895
  const { device, state } = await ensureDaemon(deviceQuery, options);
844
896
  const udid = device.udid;
845
897
 
@@ -855,8 +907,8 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
855
907
  });
856
908
  const current = settledFrame ?? state;
857
909
  let entry = fresh ? null : screenmap.recallNearest(udid, current.layoutHash)?.entry;
910
+ const geo = await deviceGeometry(udid, current);
858
911
  if (!entry) {
859
- const geo = await deviceGeometry(udid, current);
860
912
  entry = await screenmap.build(udid, {
861
913
  hash: current.hash,
862
914
  layoutHash: current.layoutHash,
@@ -872,10 +924,16 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
872
924
  keyboard: Boolean(entry.keyboard),
873
925
  layoutHash: current.layoutHash,
874
926
  settled,
927
+ // Carried out so callers that want the elements as well as the identity
928
+ // do not pay for a second perception pass to get them. The compact
929
+ // screen map needs both, and reading twice was the whole cost of it.
930
+ entry,
931
+ state: current,
932
+ points: { width: geo.pointWidth, height: geo.pointHeight },
875
933
  };
876
934
  };
877
935
 
878
- let identity = await read();
936
+ let identity = await read({ fresh });
879
937
  if (!confirmNovel) return { ...identity, confirmed: identity.settled };
880
938
  // Being recognised is stronger evidence than the pixel settle flag: a
881
939
  // fingerprint that matches a screen already trusted has nothing left to
package/src/input.js CHANGED
@@ -34,6 +34,55 @@ export async function driverFor(udid) {
34
34
  return detectDriver();
35
35
  }
36
36
 
37
+ /**
38
+ * An escape hatch back to idb for the tree.
39
+ *
40
+ * Every private-framework path here is version-coupled, and the host-side
41
+ * translator is no exception: an Xcode upgrade could break it on a machine
42
+ * where work still has to happen that day. Reading it per call rather than
43
+ * caching means the switch takes effect without restarting anything.
44
+ */
45
+ function preferIdbTree() {
46
+ return process.env.SIMFRAME_AX_DRIVER === 'idb';
47
+ }
48
+
49
+ /**
50
+ * Which driver reads the accessibility tree for a device.
51
+ *
52
+ * Separate from `driverFor` because these are separate capabilities: a device
53
+ * can be perfectly touchable by the daemon while the translation framework is
54
+ * missing, and reporting one number for both hides which layer is down.
55
+ */
56
+ export async function axDriverFor(udid) {
57
+ if (udid && control.available(udid) && !preferIdbTree()) {
58
+ try {
59
+ const status = await control.status(udid);
60
+ if (status.accessibility?.available) {
61
+ return { name: 'simframed', available: true, chosen: false, version: status.accessibility.detail, reason: null };
62
+ }
63
+ // The daemon is up and says it cannot read the tree. idb might still,
64
+ // so this is a reason to fall through rather than an answer.
65
+ } catch {
66
+ /* daemon went away mid-call; fall through to idb */
67
+ }
68
+ }
69
+ const idbDriver = await detectDriver();
70
+ // Asked for, or fallen back to? A driver someone chose is not a degradation,
71
+ // and grading it as one turns the documented escape hatch into a red CI run
72
+ // on exactly the day an Xcode upgrade makes you reach for it.
73
+ const chosen = preferIdbTree();
74
+ if (idbDriver.available) {
75
+ return {
76
+ name: 'idb',
77
+ available: true,
78
+ chosen,
79
+ version: chosen ? `${idbDriver.version} — selected by SIMFRAME_AX_DRIVER` : idbDriver.version,
80
+ reason: null,
81
+ };
82
+ }
83
+ return { name: null, available: false, chosen, version: null, reason: idbDriver.reason };
84
+ }
85
+
37
86
  /** @returns {Promise<{name: string, available: boolean, version: string|null, reason: string|null}>} */
38
87
  export async function detectDriver({ refresh = false } = {}) {
39
88
  if (driverCache && !refresh) return driverCache;
@@ -128,6 +177,20 @@ async function readScreenInfo(udid) {
128
177
 
129
178
  /** The accessibility tree, flattened. This is what makes tap-by-label possible. */
130
179
  export async function describeAll(udid) {
180
+ // The daemon reads the tree host-side through AXPTranslator: no install, and
181
+ // measured at 45ms against idb's 203ms on the same screen. idb stays as the
182
+ // fallback, so a machine without the daemon still reads.
183
+ if (control.available(udid) && !preferIdbTree()) {
184
+ try {
185
+ const { screen } = await control.request(udid, { action: 'ui', ocr: false });
186
+ // An app mid-launch genuinely has no tree yet. Falling through to idb
187
+ // here would just ask a second time and report the same emptiness more
188
+ // slowly, so the honest answer is the empty one.
189
+ if (screen?.sources?.includes('ax')) return (screen.elements ?? []).map(elementToNode);
190
+ } catch {
191
+ /* daemon went away mid-call; fall through to idb */
192
+ }
193
+ }
131
194
  // Passing --json here yields empty output; the default already emits JSON.
132
195
  const out = await idb(['ui', 'describe-all', '--udid', udid]);
133
196
  const nodes = [];
@@ -149,6 +212,20 @@ export async function describeAll(udid) {
149
212
  return nodes.filter((n) => n.frame);
150
213
  }
151
214
 
215
+ /** A daemon element back into the node shape every caller here expects. */
216
+ export function elementToNode(e) {
217
+ return {
218
+ label: cleanLabel(e.label),
219
+ rawLabel: e.label ?? null,
220
+ value: e.value ?? null,
221
+ type: e.role ?? null,
222
+ identifier: e.identifier ?? null,
223
+ enabled: e.state?.enabled ?? null,
224
+ frame: e.frame ?? null,
225
+ raw: e,
226
+ };
227
+ }
228
+
152
229
  // Icon fonts put glyphs in the Unicode private use areas, so a label arrives as
153
230
  // "<glyph>, My Tools". Matching has to see through that to the readable text.
154
231
  const PRIVATE_USE = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu;
@@ -263,6 +340,28 @@ export async function pressKey(udid, keycode) {
263
340
  await idb(['ui', 'key', '--udid', udid, String(keycode)]);
264
341
  }
265
342
 
343
+ /**
344
+ * Rebuild the daemon's HID session.
345
+ *
346
+ * Input is the one path with no feedback: a dispatched Indigo message reports
347
+ * success when the send succeeds, and nothing asks the device whether anything
348
+ * happened. Measured on a long-running daemon, a HOME press returned in 66ms
349
+ * and the screen did not move; the same press on a freshly started daemon
350
+ * worked. Whoever holds the frames is the only one who can notice, which is why
351
+ * this is something callers invoke rather than something input does for itself.
352
+ *
353
+ * @returns {Promise<boolean>} whether a session was actually reset.
354
+ */
355
+ export async function resetSession(udid) {
356
+ if (!control.available(udid)) return false;
357
+ try {
358
+ await control.resetInput(udid);
359
+ return true;
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
266
365
  export async function pressButton(udid, name) {
267
366
  if (control.available(udid)) {
268
367
  try {
package/src/matching.js CHANGED
@@ -160,12 +160,83 @@ export const AMBIGUITY_MARGIN = 0.08;
160
160
  /** Below this, no candidate is worth acting on. */
161
161
  export const MINIMUM_SCORE = 0.45;
162
162
 
163
+ /**
164
+ * How close two tap points have to be to mean the same control.
165
+ *
166
+ * Deliberately small. Two genuinely different controls are not twelve points
167
+ * apart centre to centre on any screen iOS lays out; two *readings* of one
168
+ * control are one or two points apart, because the accessibility tree and OCR
169
+ * are describing the same rectangle. Measured on a real filter row: the tree
170
+ * published "Location (All)" at (201,181) and OCR read "Location (AII)" at
171
+ * (200,182), and the caller was asked which of the two it meant.
172
+ */
173
+ export const SAME_CONTROL_POINTS = 12;
174
+
175
+ /**
176
+ * Two candidates in the same place are one control read twice.
177
+ *
178
+ * Asking which one was meant is not caution here, it is a question with no
179
+ * answer — either tap lands on the same pixel. So the readings are collapsed,
180
+ * and the accessibility one wins, because it is the actual hit target and its
181
+ * label has not been through OCR.
182
+ */
183
+ const INTERACTIVE_ROLE = /button|field|cell|row|link|switch|slider|tab|menu|segment|checkbox/i;
184
+
185
+ const contains = (frame, target) =>
186
+ Boolean(frame)
187
+ && target.x >= frame.x && target.x <= frame.x + (frame.width ?? 0)
188
+ && target.y >= frame.y && target.y <= frame.y + (frame.height ?? 0);
189
+
190
+ /**
191
+ * Are these two candidates the same control?
192
+ *
193
+ * Two ways, and the second one cost a measurement. Centres a couple of points
194
+ * apart are one rectangle read twice. But a full-width list cell and the
195
+ * left-aligned text printed inside it have centres a hundred points apart and
196
+ * are still one tap target — measured on a Settings list, where "General" came
197
+ * back as the cell at (201,326) and the OCR text at (102,327) and the caller
198
+ * was asked which of the two it meant. The screen map already folds that pair
199
+ * into one row; this is `locate` catching up with it.
200
+ */
201
+ function sameControl(a, b) {
202
+ if (Math.abs(a.x - b.x) <= SAME_CONTROL_POINTS && Math.abs(a.y - b.y) <= SAME_CONTROL_POINTS) return true;
203
+ // Containment only counts when the container is a hit target. A group that
204
+ // merely encloses things is not the thing inside it, which is what stops a
205
+ // tab bar from absorbing its own tabs.
206
+ if (INTERACTIVE_ROLE.test(a.type ?? '') && contains(a.frame, b)) return true;
207
+ if (INTERACTIVE_ROLE.test(b.type ?? '') && contains(b.frame, a)) return true;
208
+ return false;
209
+ }
210
+
211
+ function collapseSamePlace(ranked) {
212
+ const kept = [];
213
+ for (const c of ranked) {
214
+ const twin = kept.find((k) => sameControl(k.target, c.target));
215
+ if (!twin) {
216
+ kept.push(c);
217
+ continue;
218
+ }
219
+ // Prefer the real hit target: an accessibility element over OCR's reading of
220
+ // it, and an interactive role over a caption sitting inside it.
221
+ const better = (candidate, incumbent) => {
222
+ if (candidate.target.source === 'ax' && incumbent.target.source !== 'ax') return true;
223
+ if (candidate.target.source !== 'ax' && incumbent.target.source === 'ax') return false;
224
+ return INTERACTIVE_ROLE.test(candidate.target.type ?? '')
225
+ && !INTERACTIVE_ROLE.test(incumbent.target.type ?? '');
226
+ };
227
+ if (better(c, twin)) {
228
+ kept[kept.indexOf(twin)] = { ...c, reasons: [...c.reasons, 'the hit target, not the text printed on it'] };
229
+ }
230
+ }
231
+ return kept;
232
+ }
233
+
163
234
  /**
164
235
  * Resolve an intent to one element, or say why not.
165
236
  * @returns {{status: 'ok'|'ambiguous'|'none', target?, score?, reasons?, alternatives?}}
166
237
  */
167
238
  export function resolve(targets, intent, options = {}) {
168
- const ranked = rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE);
239
+ const ranked = collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE));
169
240
  if (!ranked.length) return { status: 'none', alternatives: [] };
170
241
  const [best, second] = ranked;
171
242
  if (second && best.score - second.score < AMBIGUITY_MARGIN) {