simframe 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/supervisor.js CHANGED
@@ -33,6 +33,7 @@
33
33
  import path from 'node:path';
34
34
  import { fileURLToPath } from 'node:url';
35
35
  import * as store from './store.js';
36
+ import * as ollama from './ollama.js';
36
37
  import { compiler, lineServer } from './localhelper.js';
37
38
 
38
39
  const SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'native', 'supervise.swift');
@@ -45,6 +46,32 @@ const helper = lineServer({
45
46
 
46
47
  export const DECISIONS = new Set(['wait', 'retry', 'stop']);
47
48
 
49
+ /**
50
+ * Which arm answers.
51
+ *
52
+ * Two, and the second one is an experiment rather than a recommendation. The
53
+ * owner asked for the capacity question to be settled with numbers instead of
54
+ * speculation, and a comparison needs something to compare against. `apple` is
55
+ * the shipped arm; `ollama:<model>` asks a local Ollama, off unless named.
56
+ *
57
+ * A backend is an object with `ask` and `status` and nothing else, which is the
58
+ * same shape the platform boundary uses one layer down — so `judge` below
59
+ * cannot tell which arm it asked, including in the failure shapes.
60
+ */
61
+ function backendFor(want) {
62
+ if (want === 'apple') return { kind: 'apple', ask: helper.ask, status: appleStatus };
63
+ if (want === 'ollama' || want.startsWith('ollama:')) {
64
+ const target = ollama.parseTarget(want);
65
+ return {
66
+ kind: 'ollama',
67
+ target,
68
+ ask: (situation, timeoutMs) => ollama.ask(target, situation, timeoutMs),
69
+ status: () => ollamaStatus(target),
70
+ };
71
+ }
72
+ return null;
73
+ }
74
+
48
75
  /**
49
76
  * The vocabulary gate, as a function so it can be *tested* rather than grepped.
50
77
  *
@@ -57,6 +84,18 @@ export const DECISIONS = new Set(['wait', 'retry', 'stop']);
57
84
  * text — so it broke when the branch grew an else, with nothing actually wrong.
58
85
  * A property this important deserves an assertion that runs it.
59
86
  */
87
+ /**
88
+ * The fourth word, and why it is not in `DECISIONS`.
89
+ *
90
+ * `abstain` means "I cannot tell from what I was given", and the only correct
91
+ * thing to do with it is exactly what this module already does with every
92
+ * failure: return `null`, and behave as if there is no supervisor. So it is a
93
+ * recognised *answer* and not a recognised *decision*, and keeping those apart
94
+ * is the point — a caller must never be able to act on it, and a log must be
95
+ * able to tell it from a timeout, a refusal and a model that was never there.
96
+ */
97
+ export const ABSTAIN = 'abstain';
98
+
60
99
  export function decisionOf(answer) {
61
100
  // A string, checked rather than coerced. `String(["wait"])` is `"wait"`, so a
62
101
  // `String(...)` coercion here let `{decision: ["wait"]}` through the one gate
@@ -86,11 +125,17 @@ export function requested(options) {
86
125
  */
87
126
  export async function judge({
88
127
  goal, step, expected, failure, screen, stillMs, note, options, timeoutMs = 2500,
89
- detail,
128
+ detail, mayAbstain = false,
90
129
  } = {}) {
91
- if (!requested(options)) return null;
130
+ const want = requested(options);
131
+ if (!want) return null;
92
132
  if (!step || !failure) return null;
93
- const answer = await helper.ask({
133
+ const backend = backendFor(want);
134
+ if (!backend) {
135
+ if (detail && typeof detail === 'object') detail.kind = `no such supervisor backend: "${want}"`;
136
+ return null;
137
+ }
138
+ const answer = await backend.ask({
94
139
  goal: goal ? String(goal).slice(0, 200) : null,
95
140
  step: String(step).slice(0, 200),
96
141
  expected: expected ? String(expected).slice(0, 300) : null,
@@ -98,6 +143,7 @@ export async function judge({
98
143
  screen: (screen ?? []).filter(Boolean).map((s) => String(s).slice(0, 40)).slice(0, 25),
99
144
  stillMs: Number.isFinite(stillMs) ? Math.round(stillMs) : null,
100
145
  note: note ? String(note).slice(0, 200) : null,
146
+ mayAbstain: Boolean(mayAbstain),
101
147
  }, timeoutMs);
102
148
  const decision = decisionOf(answer);
103
149
  if (decision == null) {
@@ -111,7 +157,12 @@ export async function judge({
111
157
  // supervisor did not answer": a timeout, a guardrail refusal and a model
112
158
  // that was never installed were one indistinguishable line.
113
159
  if (detail && typeof detail === 'object') {
114
- detail.kind = answer?.kind
160
+ // An abstention is an answer, and the least interesting thing a log can
161
+ // say about it is that the supervisor "did not answer". It is the model
162
+ // declining on purpose, which is the one failure mode worth encouraging.
163
+ detail.kind = answer?.decision === ABSTAIN
164
+ ? 'abstained'
165
+ : answer?.kind
115
166
  ?? (answer == null ? 'no answer' : answer.decision ? 'outside the vocabulary' : 'unparseable');
116
167
  if (answer?.error) detail.error = String(answer.error).slice(0, 200);
117
168
  }
@@ -124,7 +175,59 @@ export async function judge({
124
175
  export async function status(options) {
125
176
  const want = requested(options);
126
177
  if (!want) return { supervisor: 'none', detail: 'not requested (SIMFRAME_SUPERVISOR is unset)' };
127
- if (want !== 'apple') return { supervisor: 'none', detail: `no such supervisor backend: "${want}"` };
178
+ const backend = backendFor(want);
179
+ if (!backend) return { supervisor: 'none', detail: `no such supervisor backend: "${want}"` };
180
+ return backend.status();
181
+ }
182
+
183
+ /**
184
+ * The experiment arm, reported exactly as honestly as the shipped one.
185
+ *
186
+ * Same probe, because "available" from a presence check is the failure this
187
+ * project has already paid for: the model had stopped answering inside the
188
+ * long-lived server while `doctor`, in its own process, said it was healthy —
189
+ * for twenty calls and six failures during which nothing was being judged.
190
+ *
191
+ * A wide probe budget on purpose. This arm loads several gigabytes on first
192
+ * ask, and a cold load timing out would report a working model as broken —
193
+ * which is a different lie from the one above and just as useless.
194
+ */
195
+ async function ollamaStatus(target) {
196
+ const live = await ollama.status(target);
197
+ if (!live.ok) return { supervisor: 'none', detail: live.reason };
198
+ // Weights first, then the clock. Without this the probe measured a disk read
199
+ // and called a working model broken.
200
+ const warm = await ollama.preload(target);
201
+ if (!warm.ok) {
202
+ return {
203
+ supervisor: 'none',
204
+ detail: warm.kind === 'timeout'
205
+ ? `"${target.model}" is still loading after 120s — ask again once it is resident`
206
+ : `"${target.model}" would not load (${warm.kind}: ${warm.error})`,
207
+ };
208
+ }
209
+ const probe = await ollama.ask(target, {
210
+ step: 'tap "Probe"',
211
+ failure: '"Probe" is not on this screen. Visible: Probe',
212
+ screen: ['Probe'],
213
+ stillMs: 5000,
214
+ }, live.timeoutMs);
215
+ if (decisionOf(probe) == null) {
216
+ return {
217
+ supervisor: 'none',
218
+ detail: `Ollama has "${target.model}" and it did not answer a probe`
219
+ + `${probe?.kind ? ` (${probe.kind}${probe.error ? `: ${probe.error}` : ''})` : ''}`,
220
+ };
221
+ }
222
+ return {
223
+ supervisor: `ollama:${target.model}`,
224
+ detail: `${target.model} via Ollama at ${target.host}; answered a probe in ${probe.ms ?? '?'}ms;`
225
+ + ' schema-constrained to wait/retry/stop. An experiment arm, not a recommendation —'
226
+ + ' see docs/EXPERIMENTS.md for what it measured.',
227
+ };
228
+ }
229
+
230
+ async function appleStatus() {
128
231
  const live = await helper.status();
129
232
  if (!live.ok) return { supervisor: 'none', detail: live.reason };
130
233
  // Prove a round trip, not a presence.
package/src/view.js CHANGED
@@ -247,8 +247,26 @@ export function actsInteractive(t) {
247
247
  export function rowsFor(entry, { screen, filter, interactive, all = false, limit = DEFAULT_LIMIT } = {}) {
248
248
  let kept = (entry?.targets ?? []).map((t) => ({ ...t })).filter((t) => {
249
249
  if (!isNum(t.x) || !isNum(t.y)) return false;
250
- // Off-screen elements are real in the tree and untappable in fact.
251
- if (regions.offViewport(t, screen)) return false;
250
+ // Off-screen elements are real in the tree and untappable in fact —
251
+ // *unless* enough of one is on screen to tap, which the centre test cannot
252
+ // see. A filter chip showing 29pt of itself at the right edge was dropped
253
+ // while OCR's reading of that same sliver, labelled "Flc", was kept and
254
+ // printed in its place. Item 121: say so or clamp. Both, here — the row is
255
+ // kept, its tap point is moved to the centre of the visible part, and it is
256
+ // marked so nobody reads a clamped coordinate as a whole element.
257
+ //
258
+ // Deliberately NOT also relaxing `locate`. A clipped element still answers
259
+ // "in the tree but not in view", so `scrollTo` keeps scrolling to it rather
260
+ // than declaring a sliver good enough — that conservatism was bought with a
261
+ // reported session lost to `"'Assigned to Me' is in view at 422,277
262
+ // already"`, and widening it here would buy the same bug back.
263
+ if (regions.offViewport(t, screen)) {
264
+ const clip = regions.clipping(t, screen);
265
+ if (!clip?.usable) return false;
266
+ t.clipped = true;
267
+ t.x = clip.point.x;
268
+ t.y = clip.point.y;
269
+ }
252
270
  if (!all && !regions.offerable(t.region)) return false;
253
271
  if (!all && isNoise(t)) return false;
254
272
  return true;
@@ -380,11 +398,42 @@ function valueNote(r) {
380
398
  return `= ${v}`;
381
399
  }
382
400
 
401
+ /**
402
+ * What to call a row.
403
+ *
404
+ * A control with no accessibility label is not necessarily anonymous, and the
405
+ * order here is by how much the name can be trusted. Its own label first. Then
406
+ * its `testID`, which is a name a developer chose and which `tap` has always
407
+ * matched on. Then OCR's reading of it, which is how an icon-only control gets
408
+ * a name at all and which cannot contradict a label it does not have. Only when
409
+ * all three are missing is it really unaddressable, and that is the case item
410
+ * 122 is about: three field reports in a row spent their time on controls that
411
+ * existed, were tappable, and could be reached by no selector at all.
412
+ */
413
+ export function displayName(r) {
414
+ return trim(r.label)
415
+ || trim(r.identifier)
416
+ // Only an alias with a word in it. OCR reads the three dots of an overflow
417
+ // menu as `...`, and on the first live run of this the button was named
418
+ // `#1 button 364,84 ...` — which looks like a name, cannot be typed into a
419
+ // selector, and made the count below say there was nothing unnamed here.
420
+ // `isNoise` already refuses such text as a row of its own; it must not get
421
+ // in through the alias door either.
422
+ || (r.aliases ?? []).find((a) => alnum(a))
423
+ || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)');
424
+ }
425
+
426
+ /** A row the map can print and no caller can name. */
427
+ export const unaddressable = (r) =>
428
+ matching.isAxTarget(r) && !trim(r.label) && !trim(r.identifier)
429
+ && !(r.aliases ?? []).some((a) => alnum(a));
430
+
383
431
  function renderRow(r) {
432
+ const shown = displayName(r);
384
433
  const name = [
385
- trim(r.label) || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)'),
434
+ shown,
386
435
  valueNote(r),
387
- aliasNote(r),
436
+ aliasNote(r, shown),
388
437
  ].filter(Boolean).join(' ');
389
438
  const state = [
390
439
  r.enabled === false ? 'disabled' : null,
@@ -395,7 +444,9 @@ function renderRow(r) {
395
444
  shortType(r.type).padEnd(9),
396
445
  `${r.x},${r.y}`.padEnd(9),
397
446
  state ? `${state} ` : '',
398
- name,
447
+ // After the name, and appended rather than added as a column: an extra
448
+ // element in this join puts an extra space on *every* row, clipped or not.
449
+ r.clipped ? `${name} (partly off-screen — the coordinate is the middle of the visible part)` : name,
399
450
  ].join(' ');
400
451
  }
401
452
 
@@ -406,11 +457,14 @@ function renderRow(r) {
406
457
  * for, which is useful when they disagree and pure cost when they agree —
407
458
  * "WELCOME ~ WELCOME" was a third of some rows.
408
459
  */
409
- function aliasNote(r) {
460
+ function aliasNote(r, shown = r.label) {
410
461
  const extra = (r.aliases ?? [])
411
462
  .filter((a) => {
412
463
  const t = alnum(a);
413
- return t && !alnum(r.label).includes(t);
464
+ // Compared against the name actually printed, not against the label. An
465
+ // unlabelled control is named by its own alias now, and comparing against
466
+ // an absent label printed every one of them twice: `Sort ~ Sort`.
467
+ return t && !alnum(shown).includes(t);
414
468
  })
415
469
  .slice(0, 2);
416
470
  return extra.length ? `~ ${trim(extra.join(' '))}` : null;
@@ -511,6 +565,25 @@ export async function screenMap(deviceQuery, {
511
565
  + ' you did not expect to see as belonging to the layer underneath'
512
566
  : null;
513
567
 
568
+ // Controls that are on the screen and answer to no name — item 122.
569
+ //
570
+ // Counted from the rows about to be printed rather than from the map, so it
571
+ // is a statement about what the caller can see. The wording is the ask,
572
+ // near-verbatim from the report that made it: *"a line like 'N on-screen
573
+ // tappable views have no accessibility label — they cannot be addressed by
574
+ // selector' would push people toward instrumenting, which is the outcome
575
+ // everyone wants"*. It is also the honest answer to the second half of that
576
+ // item, which we cannot fix from here: a view UIKit was never told is
577
+ // accessible is invisible to the tree, so a screen whose controls are all
578
+ // undeclared shows this count as 0 and still needs a screenshot.
579
+ const anonymous = rows.filter(unaddressable).length;
580
+ const unnamed = anonymous
581
+ ? `${anonymous} on-screen control(s) have no accessibility label — they are listed with their`
582
+ + ' coordinates and can be tapped by point or by #ref, but not by name. If what you are'
583
+ + ' looking for is not in the list either, the app has views that were never declared'
584
+ + ' accessible and only a screenshot will find those.'
585
+ : null;
586
+
514
587
  return {
515
588
  device,
516
589
  identity,
@@ -529,7 +602,8 @@ export async function screenMap(deviceQuery, {
529
602
  staleExits,
530
603
  cleared,
531
604
  overlay,
532
- text: render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, cleared, overlay }),
605
+ unnamed,
606
+ text: render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, cleared, overlay, unnamed }),
533
607
  };
534
608
  }
535
609
 
@@ -712,7 +786,7 @@ export function ambiguousLabels(rows) {
712
786
  return [...seen.values()].filter((n) => n > 1).length;
713
787
  }
714
788
 
715
- export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay }) {
789
+ export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay, unnamed }) {
716
790
  const head = [
717
791
  device?.name,
718
792
  screen?.width ? `${screen.width}x${screen.height}pt` : null,
@@ -747,6 +821,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
747
821
  // already believes, which is the one kind of news that must not be scrolled to.
748
822
  if (cleared) lines.push(cleared);
749
823
  if (overlay) lines.push(overlay);
824
+ if (unnamed) lines.push(unnamed);
750
825
  const worked = exitsLine(exitList, { stale: staleExits });
751
826
  if (worked) lines.push(worked);
752
827