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/index.js CHANGED
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { DEFAULTS, STATE_VERSION } from './daemon.js';
7
+ import * as engine from './engine.js';
7
8
  import { decodePng, encodePng, scaleBitmap } from './png.js';
8
9
  import {
9
10
  REGION_COLS,
@@ -13,6 +14,10 @@ import {
13
14
  signatureDiff,
14
15
  } from './analyze.js';
15
16
  import * as input from './input.js';
17
+ import * as fingerprint from './fingerprint.js';
18
+ import * as graph from './graph.js';
19
+ import * as matching from './matching.js';
20
+ import * as refs from './refs.js';
16
21
  import * as screenmap from './screenmap.js';
17
22
  import { resolveDevice, resize, screenshot } from './simctl.js';
18
23
  import * as store from './store.js';
@@ -21,7 +26,24 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
26
  const CLI = path.join(HERE, 'cli.js');
22
27
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
28
 
24
- 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
+ }
25
47
 
26
48
  export function resolveMaxDim(detail) {
27
49
  if (typeof detail === 'number') return detail;
@@ -37,6 +59,22 @@ export function resolveMaxDim(detail) {
37
59
  async function fullFrameFor(udid, state) {
38
60
  if (state.fullFile && fs.existsSync(state.fullFile)) return state.fullFile;
39
61
  const p = store.paths(udid);
62
+ // The pointer in state.json can name a frame retention has already thinned
63
+ // away, and it does so routinely — state named full/2475.png while the
64
+ // directory held 2470, 2869 and 2870. Falling straight through to simctl
65
+ // meant OCR quietly shelled out for a screenshot on a machine whose daemon
66
+ // was capturing full frames the whole time: slower, and it made the whole
67
+ // step fail on a runner where that shell-out did not work.
68
+ try {
69
+ const newest = fs.readdirSync(p.full)
70
+ .filter((f) => f.endsWith('.png'))
71
+ .map((f) => ({ f, seq: Number.parseInt(f, 10) }))
72
+ .filter((x) => Number.isFinite(x.seq))
73
+ .sort((a, b) => b.seq - a.seq)[0];
74
+ if (newest) return path.join(p.full, newest.f);
75
+ } catch {
76
+ /* no full directory yet; fall through */
77
+ }
40
78
  const file = path.join(p.dir, 'ocr-source.png');
41
79
  await screenshot(udid, file, { mask: 'ignored' });
42
80
  return file;
@@ -54,11 +92,16 @@ async function deviceGeometry(udid, state) {
54
92
  } catch {
55
93
  /* idb absent: fall through */
56
94
  }
95
+ // Last resort only. These numbers are an iPhone 17 Pro, so on anything else —
96
+ // an iPad especially — they are silently wrong, and every tap point derived
97
+ // from them lands in the wrong place. screenInfo asks the daemon first now,
98
+ // so reaching here means neither the daemon nor idb could answer.
57
99
  const density = 3;
58
100
  return {
59
101
  density,
60
102
  pointWidth: Math.round((state.width * (state.nativeScale ?? 1)) / 1) || 402,
61
103
  pointHeight: Math.round((state.height * (state.nativeScale ?? 1)) / 1) || 874,
104
+ guessed: true,
62
105
  };
63
106
  }
64
107
 
@@ -89,7 +132,7 @@ export async function ensureDaemon(deviceQuery, options = {}) {
89
132
  if (!existing.alive) {
90
133
  if (acquireSpawnLock(p.lock)) {
91
134
  try {
92
- spawnDaemon(device.udid, options);
135
+ await startEngine(device.udid, options);
93
136
  } finally {
94
137
  // Hold the lock briefly so a burst of callers does not double-spawn.
95
138
  setTimeout(() => releaseSpawnLock(p.lock), 1500).unref?.();
@@ -97,7 +140,8 @@ export async function ensureDaemon(deviceQuery, options = {}) {
97
140
  }
98
141
  }
99
142
 
100
- const deadline = Date.now() + (options.readyTimeoutMs ?? 8000);
143
+ // The daemon may need a first build, which is slower than a spawn.
144
+ const deadline = Date.now() + (options.readyTimeoutMs ?? 20_000);
101
145
  while (Date.now() < deadline) {
102
146
  const state = store.readJson(p.state);
103
147
  if (state && state.capturedAt >= minCapturedAt && Date.now() - state.capturedAt < 30_000) {
@@ -109,7 +153,63 @@ export async function ensureDaemon(deviceQuery, options = {}) {
109
153
  throw new Error(`simframe daemon did not produce a frame for ${device.name}${tail ? `\n${tail}` : ''}`);
110
154
  }
111
155
 
112
- function spawnDaemon(udid, options) {
156
+ /** Why the daemon was not used, when it was not. Surfaced by doctor. */
157
+ export let engineFallbackReason = null;
158
+
159
+ /**
160
+ * Degrading has to announce itself.
161
+ *
162
+ * "Degrade rather than fail" is the right policy and it nearly sank the tool
163
+ * twice: a file missing from the published package made every install fall back
164
+ * to the simctl engine, and OCR ship disabled, both **silently**. Tests passed,
165
+ * CI passed, nothing printed. The failure was not the missing file; it was that
166
+ * the degradation was invisible.
167
+ *
168
+ * So the reason is written next to the device's state, not just held in this
169
+ * process's memory — otherwise a later `doctor` or `start` sees `engine=simctl`
170
+ * with no explanation, because the process that chose it has exited.
171
+ */
172
+ function recordFallback(udid, reason) {
173
+ const file = path.join(store.deviceDir(udid), 'engine-fallback.json');
174
+ try {
175
+ if (reason) store.writeAtomic(file, JSON.stringify({ reason, at: Date.now() }));
176
+ else fs.rmSync(file, { force: true });
177
+ } catch {
178
+ // Never let bookkeeping stop capture from starting.
179
+ }
180
+ }
181
+
182
+ /** Why the running engine is not simframed, if it is not. Survives the process that chose it. */
183
+ export function fallbackReason(udid) {
184
+ if (engineFallbackReason) return engineFallbackReason;
185
+ return store.readJson(path.join(store.deviceDir(udid), 'engine-fallback.json'))?.reason ?? null;
186
+ }
187
+
188
+ /**
189
+ * Start whichever engine was asked for.
190
+ *
191
+ * simframed unless told otherwise: it reads the framebuffer directly and is
192
+ * roughly thirty times faster per frame. The simctl loop stays reachable with
193
+ * `engine: 'simctl'`, and is used automatically when the daemon cannot be
194
+ * built — a machine with no Swift toolchain still has to work.
195
+ */
196
+ async function startEngine(udid, options) {
197
+ if ((options.engine ?? 'simframed') === 'simframed') {
198
+ const built = await engine.ensureBuilt();
199
+ if (built.ok) {
200
+ engineFallbackReason = null;
201
+ recordFallback(udid, null);
202
+ engine.spawnDaemon(udid, options);
203
+ return 'simframed';
204
+ }
205
+ engineFallbackReason = built.reason ?? 'simframed unavailable';
206
+ recordFallback(udid, engineFallbackReason);
207
+ }
208
+ spawnNodeDaemon(udid, options);
209
+ return 'simctl';
210
+ }
211
+
212
+ function spawnNodeDaemon(udid, options) {
113
213
  const args = [CLI, 'daemon', udid];
114
214
  for (const key of ['fps', 'maxDim', 'ringSize', 'idleExitMs']) {
115
215
  if (options[key] != null) args.push(`--${key}=${options[key]}`);
@@ -164,6 +264,21 @@ function readLogTail(file, lines = 6) {
164
264
  /** A frame this old means the capture loop is wedged, not that the screen is calm. */
165
265
  export const STALE_FRAME_MS = 2500;
166
266
 
267
+ /**
268
+ * How still the screen must be before a frame may be used to key screen memory.
269
+ *
270
+ * A screen map describes a screen, so it must be built from a frame that shows
271
+ * one — not from the middle of a transition, where the layout belongs to
272
+ * neither the screen you left nor the one you are arriving at. Without this,
273
+ * the capture rate leaks into the hit rate: a faster loop samples more
274
+ * transitional frames and remembers more layouts that will never recur.
275
+ *
276
+ * Phase 4 replaces this with the real settle detector, which can tell a
277
+ * spinner from a still screen. Until then, "nothing moved for a while" is
278
+ * enough to decouple memory from frame rate.
279
+ */
280
+ export const MEMORY_SETTLE_MS = 250;
281
+
167
282
  /** Below this a "change" is a clock digit or a caret, not a new screen. */
168
283
  export const MINOR_CHANGE = 0.004;
169
284
  export const MAJOR_CHANGE = 0.03;
@@ -180,7 +295,17 @@ export function liveness(udid, state) {
180
295
  if (!running) {
181
296
  return { ok: false, ageMs, note: 'the capture loop has died; the frame you are looking at is the last one it wrote' };
182
297
  }
183
- if (ageMs > STALE_FRAME_MS) {
298
+ // Frame age means "stalled" only for a fixed-rate loop.
299
+ //
300
+ // simframed captures on damage, so a screen that is genuinely still produces
301
+ // no frames at all — which is precisely the state `settle` exists to detect.
302
+ // Treating that as a stall made a flow fail on a static page with "capture
303
+ // loop is stalled: newest frame is 2984ms old" immediately after a step had
304
+ // succeeded. The pid check above is the honest liveness signal for this
305
+ // engine; the heartbeat file cannot help, because clients write it, not the
306
+ // daemon.
307
+ const damageDriven = engine.runningEngine(udid) === 'simframed';
308
+ if (!damageDriven && ageMs > STALE_FRAME_MS) {
184
309
  return { ok: false, ageMs, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
185
310
  }
186
311
  return { ok: true, ageMs, note: null };
@@ -577,15 +702,79 @@ export async function getFrameAt(deviceQuery, { msAgo = 0, options } = {}) {
577
702
  * A screen seen for the first time pays once to build its map, and every later
578
703
  * visit is a file read.
579
704
  */
580
- export async function locate(deviceQuery, query, { index, refresh = false, useAx = true, useOcr = true, options } = {}) {
581
- const { device, state } = await ensureDaemon(deviceQuery, options);
705
+ /**
706
+ * Wait, briefly, for a frame that is holding still. Returns whatever the newest
707
+ * frame is once the screen settles or the budget runs out, saying which.
708
+ */
709
+ export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutMs = 1500 } = {}) {
710
+ const p = store.paths(udid);
711
+ const deadline = Date.now() + timeoutMs;
712
+ let state = store.readJson(p.state);
713
+ while (Date.now() < deadline) {
714
+ state = store.readJson(p.state) ?? state;
715
+ // The daemon runs a real settle detector that can tell a spinner from a
716
+ // still screen. Prefer it; the duration check is the fallback for the
717
+ // simctl engine, which has no such thing.
718
+ if (state?.settled === true) return { state, settled: true };
719
+ if (state && state.settled === undefined && state.stableForMs >= settleMs) {
720
+ return { state, settled: true };
721
+ }
722
+ await sleep(40);
723
+ }
724
+ return { state, settled: false };
725
+ }
726
+
727
+ export async function locate(
728
+ deviceQuery,
729
+ query,
730
+ { index, refresh = false, useAx = true, useOcr = true, settleMs = MEMORY_SETTLE_MS, options } = {},
731
+ ) {
732
+ const { device, state: firstState } = await ensureDaemon(deviceQuery, options);
582
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;
768
+ // Key memory off a settled frame, never off whichever frame happened to be
769
+ // newest, so the capture rate cannot change what gets remembered.
770
+ const { state, settled } = await settledState(udid, { settleMs });
771
+ const current = state ?? firstState;
583
772
  let entry = null;
584
773
  let from = 'memory';
585
774
  let distance = 0;
586
775
 
587
776
  if (!refresh) {
588
- const near = screenmap.recallNearest(udid, state.layoutHash);
777
+ const near = screenmap.recallNearest(udid, current.layoutHash);
589
778
  if (near) {
590
779
  entry = near.entry;
591
780
  distance = near.distance;
@@ -593,34 +782,59 @@ export async function locate(deviceQuery, query, { index, refresh = false, useAx
593
782
  }
594
783
 
595
784
  if (!entry) {
596
- const geo = await deviceGeometry(udid, state);
785
+ const geo = await deviceGeometry(udid, current);
597
786
  entry = await screenmap.build(udid, {
598
- hash: state.hash,
599
- layoutHash: state.layoutHash,
600
- fullFrame: await fullFrameFor(udid, state),
787
+ hash: current.hash,
788
+ layoutHash: current.layoutHash,
789
+ fullFrame: await fullFrameFor(udid, current),
601
790
  density: geo.density,
602
791
  screen: { width: geo.pointWidth, height: geo.pointHeight },
603
792
  useAx,
604
793
  useOcr,
794
+ // A map built while the screen was moving describes nothing that will
795
+ // recur, so it is used for this call and then thrown away.
796
+ persist: settled,
605
797
  });
606
- from = 'built';
798
+ from = settled ? 'built' : 'built-unsettled';
607
799
  }
608
800
 
609
- const candidates = screenmap.rank(entry, query);
610
- if (candidates.length > 1 && index == null) {
611
- const top = candidates[0];
612
- const second = candidates[1];
613
- const decisive = screenmap.isInteractive(top) && !screenmap.isInteractive(second);
614
- if (!decisive) {
615
- const list = candidates
616
- .slice(0, 6)
617
- .map((t, i) => `[${i}] "${t.label}" (${t.x},${t.y}) ${t.type}/${t.source}`)
801
+ const screenSize = { width: current.width, height: current.height };
802
+ const geo = await deviceGeometry(udid, current);
803
+ const points = { width: geo.pointWidth, height: geo.pointHeight };
804
+
805
+ // Intent resolution rather than string matching: it understands verbs
806
+ // ("tap Save"), typos, icon-only controls by synonym ("back"), and where on
807
+ // screen the caller meant ("Assets tab").
808
+ if (index == null) {
809
+ const outcome = matching.resolve(entry.targets, query, { screen: points });
810
+ if (outcome.status === 'ambiguous') {
811
+ const list = outcome.alternatives
812
+ .map((a, i) => `[${i}] "${a.label}" (${a.x},${a.y}) ${a.region ?? 'content'} ${a.score}`)
618
813
  .join(', ');
619
814
  throw new Error(
620
- `"${query}" matches ${candidates.length} things on this screen — pass index to choose: ${list}`,
815
+ `"${query}" matches ${outcome.alternatives.length} things on this screen — say which, or pass index: ${list}`,
621
816
  );
622
817
  }
818
+ if (outcome.status === 'ok') {
819
+ return {
820
+ device, state: current, entry, target: outcome.target, from, distance, settled,
821
+ score: outcome.score, reasons: outcome.reasons, alternatives: outcome.alternatives,
822
+ screens: screenmap.stats(udid).screens,
823
+ };
824
+ }
825
+ // Nothing scored well enough. Falling through to plain substring matching
826
+ // here undoes every guard above — it has no off-screen filter and no
827
+ // coverage weighting, and it is what returned a scrolled-away list row for
828
+ // "back". "Not found" is the correct answer.
829
+ const sample = entry.targets
830
+ .filter((t) => t.label && t.y >= 0 && t.y <= points.height)
831
+ .slice(0, 12)
832
+ .map((t) => t.label.slice(0, 24))
833
+ .join(', ');
834
+ throw new Error(`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`);
623
835
  }
836
+
837
+ const candidates = screenmap.rank(entry, query);
624
838
  const target = index != null ? candidates[index] : candidates[0];
625
839
  if (!target) {
626
840
  const sample = entry.targets
@@ -632,7 +846,120 @@ export async function locate(deviceQuery, query, { index, refresh = false, useAx
632
846
  `"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`,
633
847
  );
634
848
  }
635
- return { device, state, entry, target, from, distance, screens: screenmap.stats(udid).screens };
849
+ return { device, state: current, entry, target, from, distance, settled, screens: screenmap.stats(udid).screens };
850
+ }
851
+
852
+ /**
853
+ * What screen this is, structurally.
854
+ *
855
+ * Uses the screen map — recalled when the pixel index finds one, built when it
856
+ * does not. The pixel hash is what makes the lookup cheap; the structural hash
857
+ * is what makes the answer right.
858
+ */
859
+ /**
860
+ * Structural settle: how long to let a screen finish arriving before believing
861
+ * a fingerprint that nothing recognises.
862
+ */
863
+ export const STRUCTURAL_SETTLE_MS = 300;
864
+
865
+ /**
866
+ * How long identity will wait for pixels to go quiet.
867
+ *
868
+ * Not the caller's timeout. A flow allows twelve seconds for a screen to
869
+ * arrive, but some screens never report settled at all — live content, a
870
+ * looping animation — and spending the flow's whole budget waiting for a flag
871
+ * that will not come cost 53s on a tour that had taken 7s. Long enough to
872
+ * outlast a normal transition, short enough that a screen which never settles
873
+ * is cheap to give up on.
874
+ */
875
+ export const IDENTITY_SETTLE_TIMEOUT_MS = 2500;
876
+ const STRUCTURAL_SETTLE_SAMPLES = 3;
877
+
878
+ /**
879
+ * What screen is this?
880
+ *
881
+ * `settled` is a question about pixels, and it is answered before a screen has
882
+ * necessarily finished arriving: a list whose spinner has gone but whose rows
883
+ * have not landed is perfectly still and structurally wrong. Measured, that put
884
+ * one screen at 17 tokens on one visit and 7 on the next, which is the whole
885
+ * reason the same-screen floor sits at 0.41 instead of somewhere comfortable.
886
+ *
887
+ * So structural identity gets a structural settle of its own — but only where
888
+ * it costs something worth paying for. A fingerprint that matches a screen we
889
+ * already know is taken at face value; the risk is not that we mislabel a known
890
+ * screen, it is that a half-drawn one becomes a new node nobody can navigate
891
+ * to. Novel fingerprints, and only those, are re-sampled until two consecutive
892
+ * readings agree.
893
+ */
894
+ export async function screenIdentity(deviceQuery, { options, confirmNovel = true, settleMs, timeoutMs, fresh = false } = {}) {
895
+ const { device, state } = await ensureDaemon(deviceQuery, options);
896
+ const udid = device.udid;
897
+
898
+ const read = async ({ fresh = false } = {}) => {
899
+ // Settle with the caller's patience, not a default. settledState times out
900
+ // at 1.5s on its own, so inside a flow that allows twelve seconds this was
901
+ // calling a screen unsettled while the flow was still happily waiting for
902
+ // it — and an unsettled screen records no edge, so the graph learned
903
+ // nothing and every later step read `unverified`.
904
+ const { state: settledFrame, settled } = await settledState(udid, {
905
+ settleMs,
906
+ timeoutMs: Math.min(timeoutMs ?? IDENTITY_SETTLE_TIMEOUT_MS, IDENTITY_SETTLE_TIMEOUT_MS),
907
+ });
908
+ const current = settledFrame ?? state;
909
+ let entry = fresh ? null : screenmap.recallNearest(udid, current.layoutHash)?.entry;
910
+ const geo = await deviceGeometry(udid, current);
911
+ if (!entry) {
912
+ entry = await screenmap.build(udid, {
913
+ hash: current.hash,
914
+ layoutHash: current.layoutHash,
915
+ fullFrame: await fullFrameFor(udid, current),
916
+ density: geo.density,
917
+ screen: { width: geo.pointWidth, height: geo.pointHeight },
918
+ persist: settled,
919
+ });
920
+ }
921
+ return {
922
+ hash: entry.structuralHash,
923
+ tokens: entry.structuralTokens ?? [],
924
+ keyboard: Boolean(entry.keyboard),
925
+ layoutHash: current.layoutHash,
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 },
933
+ };
934
+ };
935
+
936
+ let identity = await read({ fresh });
937
+ if (!confirmNovel) return { ...identity, confirmed: identity.settled };
938
+ // Being recognised is stronger evidence than the pixel settle flag: a
939
+ // fingerprint that matches a screen already trusted has nothing left to
940
+ // prove, and some screens (live content, a looping animation) never report
941
+ // settled at all. The gate exists to stop a half-drawn screen becoming a new
942
+ // node — not to re-interrogate a known one.
943
+ if (graph.nearestScreen(udid, identity)) return { ...identity, confirmed: true, known: true };
944
+
945
+ // Nothing recognises this, or the pixels have not gone quiet. Either way, make
946
+ // it prove it is the same screen twice running before it becomes a node.
947
+ for (let i = 1; i < STRUCTURAL_SETTLE_SAMPLES; i += 1) {
948
+ await sleep(STRUCTURAL_SETTLE_MS);
949
+ const again = await read({ fresh: true });
950
+ // Two readings agree if they are the same screen — the same test identity
951
+ // itself uses. Demanding an identical hash is a stricter question than the
952
+ // one being asked, and a row a grid-unit wider fails it.
953
+ const agrees = again.hash === identity.hash
954
+ || fingerprint.similarity(again.tokens, identity.tokens) >= graph.SIMILARITY_THRESHOLD;
955
+ if (agrees) {
956
+ return { ...again, confirmed: true, known: Boolean(graph.nearestScreen(udid, again)) };
957
+ }
958
+ identity = again;
959
+ }
960
+ // Still moving structurally. Report the latest reading and say it is unproven,
961
+ // so callers can decline to record an edge to a screen that never held still.
962
+ return { ...identity, confirmed: false, known: false };
636
963
  }
637
964
 
638
965
  export { DEFAULTS, screenmap, store };