simframe 0.9.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.
Files changed (41) hide show
  1. package/README.md +165 -5
  2. package/data/vocabulary/en.json +148 -0
  3. package/native/ocr.swift +13 -1
  4. package/native/rank.swift +87 -0
  5. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +43 -3
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
  8. package/native/simframed/Sources/simframed/main.swift +13 -1
  9. package/native/supervise.swift +181 -0
  10. package/package.json +4 -1
  11. package/scripts/check-package.mjs +22 -2
  12. package/scripts/check-private.mjs +143 -0
  13. package/scripts/ci-memory.mjs +104 -20
  14. package/scripts/eval-perception.mjs +281 -0
  15. package/scripts/phase17-corpus.mjs +176 -0
  16. package/skills/simframe/SKILL.md +237 -5
  17. package/src/actions.js +1825 -38
  18. package/src/analyze.js +70 -0
  19. package/src/cli.js +214 -15
  20. package/src/control.js +1 -0
  21. package/src/fingerprint.js +19 -1
  22. package/src/graph.js +193 -11
  23. package/src/index.js +428 -16
  24. package/src/input.js +115 -8
  25. package/src/localhelper.js +155 -0
  26. package/src/matching.js +119 -3
  27. package/src/mcp.js +319 -27
  28. package/src/metrics.js +134 -8
  29. package/src/navigate.js +10 -7
  30. package/src/ocr.js +18 -1
  31. package/src/planner.js +195 -0
  32. package/src/platform/android.js +3 -2
  33. package/src/platform/ios.js +2 -1
  34. package/src/png.js +26 -0
  35. package/src/refs.js +51 -8
  36. package/src/regions.js +110 -1
  37. package/src/screenmap.js +109 -10
  38. package/src/supervisor.js +117 -0
  39. package/src/view.js +396 -7
  40. package/src/vocabulary.js +134 -0
  41. package/src/wrote.js +136 -0
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. */
package/src/screenmap.js CHANGED
@@ -17,7 +17,20 @@ import * as regions from './regions.js';
17
17
  import { informative } from './refs.js';
18
18
  import * as store from './store.js';
19
19
 
20
- const MAP_VERSION = 8; // an OCR reading contained in a labelled ax element merges into it
20
+ const MAP_VERSION = 9; // ax targets carry value, selected and focused
21
+
22
+ /**
23
+ * A stored map also holds a `structuralHash`, which the *fingerprint* rules
24
+ * produced. So a token-rule change invalidates every stored map, and relying on
25
+ * someone to remember to bump `MAP_VERSION` too is exactly how the phantom
26
+ * keyboard survived a fix: two copies of one dependency, one of them updated.
27
+ *
28
+ * Stating the dependency instead of remembering it. A map is only valid for the
29
+ * token rules that hashed it.
30
+ */
31
+ const usable = (e) => Boolean(e)
32
+ && e.version === MAP_VERSION
33
+ && e.fingerprintVersion === fingerprint.TOKEN_RULES_VERSION;
21
34
 
22
35
  function mapDir(udid) {
23
36
  return path.join(store.deviceDir(udid), 'screens');
@@ -38,10 +51,38 @@ function mapDir(udid) {
38
51
  */
39
52
  export const DEFAULT_TOLERANCE = 20;
40
53
 
54
+ /** Comparison that ignores what OCR adds — a caret, a stray glyph, spacing. */
55
+ const alnum = (v) => String(v ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
56
+
57
+ /**
58
+ * May an OCR word be recorded as an alias of the element enclosing it?
59
+ *
60
+ * Only when they are plausibly the same thing. A row labelled "Kate Bell"
61
+ * containing OCR's "Kate Bell" is one element two sensors saw; a sheet's
62
+ * "Area (Optional)" enclosing a dimmed page's "Exterior Building" is two things
63
+ * at one coordinate on different z-layers, and aliasing them reads as though
64
+ * the field contains that value.
65
+ *
66
+ * An element with **no label** takes the text outright — that is how an
67
+ * icon-only control gets a name, and it cannot contradict a label it does not
68
+ * have.
69
+ *
70
+ * A function rather than three lines inline, because the inline version read
71
+ * `covering.label` before anything checked that `covering` existed, and the
72
+ * TypeError that followed was swallowed by the OCR try/catch — silently
73
+ * disabling the sensor. A pure function can be tested with the value that broke
74
+ * it, and the source-shape assertion this replaces could not.
75
+ */
76
+ export function aliasRelates(coveringLabel, text) {
77
+ const own = alnum(coveringLabel);
78
+ const seen = alnum(text);
79
+ return !own || !seen || own.includes(seen) || seen.includes(own);
80
+ }
81
+
41
82
  export function recall(udid, hash) {
42
83
  if (!hash) return null;
43
84
  const entry = store.readJson(path.join(mapDir(udid), `${hash}.json`));
44
- return entry && entry.version === MAP_VERSION ? entry : null;
85
+ return usable(entry) ? entry : null;
45
86
  }
46
87
 
47
88
  function loadAll(udid) {
@@ -53,7 +94,7 @@ function loadAll(udid) {
53
94
  }
54
95
  return files
55
96
  .map((f) => store.readJson(path.join(mapDir(udid), f)))
56
- .filter((e) => e && e.version === MAP_VERSION);
97
+ .filter(usable);
57
98
  }
58
99
 
59
100
  /**
@@ -137,6 +178,9 @@ export async function build(udid, {
137
178
  // empty `sources` rethrew — so making a layer work turned a loud failure into
138
179
  // a quiet one.
139
180
  const degraded = [];
181
+ // Pairs where an ax element and an OCR word share a coordinate and disagree
182
+ // about what is there — the signature of one layer covering another.
183
+ const occluded = [];
140
184
  // One round trip for both, because the daemon runs the tree read and the
141
185
  // recognition pass concurrently against the same instant of the screen. Asked
142
186
  // separately they would queue: the control socket serves one request at a
@@ -185,11 +229,30 @@ export async function build(udid, {
185
229
  if (!n.frame || !n.label || isContainer(n)) continue;
186
230
  targets.push({
187
231
  label: n.label,
232
+ // What the control *contains*, whether it is on, and whether it has
233
+ // focus. All three come off the accessibility tree, the daemon has
234
+ // asked for all three since 0.6.0, and all three were dropped before
235
+ // this — `value` here and the other two one layer up in
236
+ // `normalizeNode` — so nothing above this line ever saw them.
237
+ //
238
+ // The cost was not theoretical. A real session could not verify the
239
+ // contents of a text field at all: they reached a row only as the OCR
240
+ // alias, which made them as old as the map and unauthoritative. An
241
+ // assert failed against a field that did contain the string, the
242
+ // operator retyped, and the field ended up with a doubled value and a
243
+ // validation error.
244
+ //
245
+ // `focused` is worth naming separately: it is a direct answer to "did
246
+ // this field take focus", which the focus wait in actions.js infers
247
+ // from elapsed time because it had nothing better to use.
248
+ value: n.value ?? undefined,
188
249
  x: input.centerOf(n).x,
189
250
  y: input.centerOf(n).y,
190
251
  frame: n.frame,
191
252
  type: n.type,
192
253
  enabled: n.enabled,
254
+ selected: n.selected ?? undefined,
255
+ focused: n.focused ?? undefined,
193
256
  source: 'ax',
194
257
  });
195
258
  }
@@ -262,15 +325,49 @@ export async function build(udid, {
262
325
  ?? targets
263
326
  .filter((t) => eligible(t) && matching.sameElementSeenTwice(t, { ...w, frame: box, label: w.text }))
264
327
  .sort((a, b) => area(a.frame) - area(b.frame))[0];
328
+ // An alias must be the *same thing*, read twice.
329
+ //
330
+ // The geometric branch above pairs an OCR word with whichever ax
331
+ // element encloses it, and across z-layers that is simply wrong.
332
+ // Reported on a modal-heavy screen, with a Select Area sheet open over a
333
+ // dimmed page: `#15 text 167,316 Area (Optional) ~ Exterior Building`,
334
+ // which reads as though the field "Area (Optional)" contains "Exterior
335
+ // Building". They are two unrelated things at one coordinate on
336
+ // different layers, and the reporter had to fall back to a screenshot to
337
+ // count five radio options — precisely the case the text map exists to
338
+ // remove.
339
+ //
340
+ // So a labelled ax element only takes an alias that relates to its own
341
+ // label. The justification for aliasing was always "a row labelled 'Kate
342
+ // Bell' containing OCR's 'Kate Bell' is one element two sensors saw" —
343
+ // that still holds. An *unlabelled* element still takes the text
344
+ // outright, because that is how an icon-only control gets a name at all,
345
+ // and it cannot contradict a label it does not have.
346
+ // NOTE the nesting, which is the whole point of this shape: `covering`
347
+ // is undefined whenever no ax element encloses this word, which is most
348
+ // words on most screens. Reading `covering.label` before checking that
349
+ // threw a TypeError inside the OCR try/catch — so the entire OCR pass
350
+ // was swallowed and reported as `degraded: text recognition`, silently
351
+ // disabling the sensor on every screen with one uncovered word. Neither
352
+ // the unit tests nor the perception harness caught it: the harness feeds
353
+ // *already fused* element lists, so it never runs this loop. The
354
+ // integration job caught it, which is what it is for.
265
355
  if (covering) {
266
- covering.aliases = [...(covering.aliases || []), w.text];
267
- // Keep the ax role and frame — it is the hit target — and record that
268
- // both sensors saw it. Anything asking "is this the tree's element?"
269
- // must ask matching.isAxTarget, not `=== 'ax'`.
270
- if (!String(covering.source ?? '').includes('ocr')) {
271
- covering.source = `${covering.source ?? 'ax'}|ocr`;
356
+ if (aliasRelates(covering.label, w.text)) {
357
+ covering.aliases = [...(covering.aliases || []), w.text];
358
+ // Keep the ax role and frame — it is the hit target — and record
359
+ // that both sensors saw it. Anything asking "is this the tree's
360
+ // element?" must ask matching.isAxTarget, not `=== 'ax'`.
361
+ if (!String(covering.source ?? '').includes('ocr')) {
362
+ covering.source = `${covering.source ?? 'ax'}|ocr`;
363
+ }
364
+ continue;
272
365
  }
273
- continue;
366
+ // Rejected as an alias, so it falls through and becomes an element of
367
+ // its own — which is what it is. Marked, because "these two things
368
+ // overlap and disagree" is exactly the shape of an occluding layer,
369
+ // and a caller counting radio options needs to know it is there.
370
+ occluded.push({ over: covering.label, under: w.text });
274
371
  }
275
372
  targets.push({
276
373
  label: w.text,
@@ -304,6 +401,7 @@ export async function build(udid, {
304
401
  : { hash: null, tokens: [], keyboard: false };
305
402
  const entry = {
306
403
  version: MAP_VERSION,
404
+ fingerprintVersion: fingerprint.TOKEN_RULES_VERSION,
307
405
  hash,
308
406
  layoutHash,
309
407
  structuralHash: structure.hash,
@@ -317,6 +415,7 @@ export async function build(udid, {
317
415
  // map has to carry it, because this is what gets written into memory.
318
416
  if (daemonScreen?.axTruncated) degraded.push(`accessibility tree cut short: ${daemonScreen.axTruncated}`);
319
417
  if (degraded.length) entry.degraded = degraded;
418
+ if (occluded.length) entry.occluded = occluded;
320
419
  // Only a map of a settled screen is worth keeping; remembering a transition
321
420
  // fills the store with layouts that will never be seen again.
322
421
  return persist ? remember(udid, entry) : entry;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The local supervisor: three words, behind the hands, in front of Claude.
3
+ *
4
+ * The owner's design. Claude plans; the deterministic executor in `actions.js`
5
+ * runs the plan and verifies each step; and when a step fails, *this* decides
6
+ * whether the plan can proceed — before anything reaches Claude. It sits behind
7
+ * the hands and in front of the reasoner, and it is the first responder rather
8
+ * than the decision-maker.
9
+ *
10
+ * It may say **wait**, **retry** or **stop**. Nothing else. It cannot invent a
11
+ * step, skip one, substitute a target, or continue past an unexpected screen —
12
+ * not because a confidence threshold forbids it but because those are not words
13
+ * it can say. The answer space *is* the safety property. `seek` was given
14
+ * latitude over what to open and pressed "YES, THIS FIXED MY PROBLEM" in a live
15
+ * app; a component that can only choose among three words cannot do that,
16
+ * whatever it believes.
17
+ *
18
+ * **The plan briefs it**, which is the owner's second insight and the thing that
19
+ * made it work. Asked cold, it called a list that was plainly still arriving a
20
+ * dead end — because it does not know the app and Claude, by the time it writes
21
+ * the plan, does. So a batch may carry `supervise` and a step may carry
22
+ * `expect`, and both reach the supervisor as context. That costs a string and no
23
+ * round trips.
24
+ *
25
+ * One thing it is deliberately not trusted for: its own **prose**. In testing it
26
+ * returned a correct decision with a reason citing a rule that did not apply.
27
+ * The decision is used; the reason is logged and never shown as an explanation.
28
+ * Presenting a confabulated rationale as fact is the mistake `seek`'s
29
+ * documentation already made once.
30
+ *
31
+ * Off unless asked: `SIMFRAME_SUPERVISOR=apple`, or `supervisor` per call.
32
+ */
33
+ import path from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import * as store from './store.js';
36
+ import { compiler, lineServer } from './localhelper.js';
37
+
38
+ const SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'native', 'supervise.swift');
39
+ const BIN = path.join(store.ROOT, 'bin', 'supervise');
40
+
41
+ const helper = lineServer({
42
+ ensureBinary: compiler({ source: SOURCE, binary: BIN, what: 'local supervisor' }),
43
+ what: 'local supervisor',
44
+ });
45
+
46
+ export const DECISIONS = new Set(['wait', 'retry', 'stop']);
47
+
48
+ /** Which backend the caller asked for, per call first and environment second. */
49
+ export function requested(options) {
50
+ const raw = String(options?.supervisor ?? process.env.SIMFRAME_SUPERVISOR ?? '').trim().toLowerCase();
51
+ if (!raw || raw === 'none' || raw === 'off' || raw === '0' || raw === 'false') return null;
52
+ return raw;
53
+ }
54
+
55
+ /**
56
+ * Can this plan proceed past the step that just failed?
57
+ *
58
+ * @returns {Promise<{decision: 'wait'|'retry'|'stop', reason: string, ms: number}|null>}
59
+ * null on every failure mode — not asked, unavailable, timed out, unparseable,
60
+ * or an answer outside the three words. A null means the executor behaves
61
+ * exactly as it does without a supervisor, which is the only safe default.
62
+ */
63
+ export async function judge({
64
+ goal, step, expected, failure, screen, stillMs, note, options, timeoutMs = 2500,
65
+ } = {}) {
66
+ if (!requested(options)) return null;
67
+ if (!step || !failure) return null;
68
+ const answer = await helper.ask({
69
+ goal: goal ? String(goal).slice(0, 200) : null,
70
+ step: String(step).slice(0, 200),
71
+ expected: expected ? String(expected).slice(0, 300) : null,
72
+ failure: String(failure).slice(0, 300),
73
+ screen: (screen ?? []).filter(Boolean).map((s) => String(s).slice(0, 40)).slice(0, 25),
74
+ stillMs: Number.isFinite(stillMs) ? Math.round(stillMs) : null,
75
+ note: note ? String(note).slice(0, 200) : null,
76
+ }, timeoutMs);
77
+ const decision = String(answer?.decision ?? '').toLowerCase();
78
+ // An answer outside the vocabulary is not a decision. Refusing it here is
79
+ // what makes the three-word constraint real rather than merely documented.
80
+ if (!DECISIONS.has(decision)) return null;
81
+ return { decision, reason: String(answer.reason ?? '').slice(0, 120), ms: answer.ms ?? null };
82
+ }
83
+
84
+ /** For `doctor`: what the supervisor layer is, in one line. */
85
+ export async function status(options) {
86
+ const want = requested(options);
87
+ if (!want) return { supervisor: 'none', detail: 'not requested (SIMFRAME_SUPERVISOR is unset)' };
88
+ if (want !== 'apple') return { supervisor: 'none', detail: `no such supervisor backend: "${want}"` };
89
+ const live = await helper.status();
90
+ if (!live.ok) return { supervisor: 'none', detail: live.reason };
91
+ // Prove a round trip, not a presence.
92
+ //
93
+ // Reporting "available" from a fresh process was true and useless: the model
94
+ // had stopped answering inside the long-lived MCP server, `doctor` opened its
95
+ // own process, got a healthy one, and said so — for twenty calls and six
96
+ // failures during which nothing was being judged. The reporter's fix, and it
97
+ // is the right one: make it answer something.
98
+ const probe = await helper.ask({
99
+ step: 'tap "Probe"',
100
+ failure: '"Probe" is not on this screen. Visible: Probe',
101
+ screen: ['Probe'],
102
+ stillMs: 5000,
103
+ }, 6000);
104
+ const decision = String(probe?.decision ?? '').toLowerCase();
105
+ if (!DECISIONS.has(decision)) {
106
+ return {
107
+ supervisor: 'none',
108
+ detail: 'the model loaded but did not answer a probe — it is present and not working',
109
+ };
110
+ }
111
+ return {
112
+ supervisor: 'apple',
113
+ detail: `Apple Foundation Models, on-device; answered a probe in ${probe.ms ?? '?'}ms; may only answer wait/retry/stop`,
114
+ };
115
+ }
116
+
117
+ export const close = helper.close;