screenhand 0.3.2 → 0.3.4

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.
@@ -17,7 +17,7 @@
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
19
  import { detectBlockers } from "./detectors.js";
20
- import { getBuiltinStrategies, parseReferenceStrategies, buildStrategyWithContext, } from "./strategies.js";
20
+ import { getBuiltinStrategies, parseReferenceStrategies, buildStrategyWithContext, parseSolutionToSteps, } from "./strategies.js";
21
21
  const DEFAULT_CONFIG = {
22
22
  referencesDir: path.join(process.cwd(), "references"),
23
23
  };
@@ -31,6 +31,7 @@ export class RecoveryEngine {
31
31
  /** Map of "blockerType:strategyId" → cooldown entry */
32
32
  strategyCooldowns = new Map();
33
33
  learningEngine = null;
34
+ appMap = null;
34
35
  constructor(worldModel, executeTool, memory, config) {
35
36
  this.worldModel = worldModel;
36
37
  this.executeTool = executeTool;
@@ -44,6 +45,12 @@ export class RecoveryEngine {
44
45
  setLearningEngine(engine) {
45
46
  this.learningEngine = engine;
46
47
  }
48
+ /**
49
+ * Wire #7: L7→L4 — Inject AppMap for contract-based recovery validation.
50
+ */
51
+ setAppMap(map) {
52
+ this.appMap = map;
53
+ }
47
54
  /**
48
55
  * Get the current status of the recovery engine.
49
56
  */
@@ -95,7 +102,16 @@ export class RecoveryEngine {
95
102
  */
96
103
  selectStrategies(blocker, budget) {
97
104
  const candidates = [];
98
- // Reference strategies first (app-specific)
105
+ // Wire #7: L7→L4 — Try contract undo paths first (most specific)
106
+ if (this.appMap && blocker.bundleId && blocker.description) {
107
+ try {
108
+ const undoStrategy = this.buildUndoStrategy(blocker);
109
+ if (undoStrategy)
110
+ candidates.push(undoStrategy);
111
+ }
112
+ catch { /* best-effort */ }
113
+ }
114
+ // Reference strategies second (app-specific)
99
115
  if (blocker.bundleId) {
100
116
  const refErrors = this.loadReferenceErrors(blocker.bundleId);
101
117
  candidates.push(...parseReferenceStrategies(refErrors, blocker.type));
@@ -181,7 +197,7 @@ export class RecoveryEngine {
181
197
  return { recovered: false, reason: "all_strategies_failed" };
182
198
  }
183
199
  }
184
- // Verify recovery
200
+ // Verify recovery (Wire #7: includes contract-based validation when available)
185
201
  await sleep(300);
186
202
  const verified = this.verifyRecovery(blocker);
187
203
  const durationMs = Date.now() - start;
@@ -282,6 +298,37 @@ export class RecoveryEngine {
282
298
  }
283
299
  }
284
300
  }
301
+ /**
302
+ * Wire #7: L7→L4 — Build an undo strategy from AppMap contract undo paths.
303
+ * If the blocker's description mentions an element that has a contract with an undoPath,
304
+ * create a recovery strategy that executes the undo action.
305
+ */
306
+ buildUndoStrategy(blocker) {
307
+ if (!this.appMap || !blocker.bundleId)
308
+ return null;
309
+ // Extract element name from blocker description
310
+ // Typical descriptions: 'Dialog appeared after click_text', 'Element not found: "Submit"'
311
+ const quotedMatch = blocker.description.match(/["']([^"']{1,60})["']/);
312
+ const elementLabel = quotedMatch?.[1];
313
+ if (!elementLabel)
314
+ return null;
315
+ const contractInfo = this.appMap.getContract(blocker.bundleId, elementLabel);
316
+ if (!contractInfo?.contract.undoPath)
317
+ return null;
318
+ const undoPath = contractInfo.contract.undoPath;
319
+ // Parse undoPath into recovery steps (e.g. "key cmd+z", "click Cancel")
320
+ const steps = parseSolutionToSteps(undoPath);
321
+ if (steps.length === 0)
322
+ return null;
323
+ return {
324
+ id: `undo_contract_${elementLabel}`,
325
+ blockerType: blocker.type,
326
+ label: `Undo via contract: ${undoPath}`,
327
+ steps,
328
+ postcondition: null,
329
+ source: "reference",
330
+ };
331
+ }
285
332
  /**
286
333
  * Load and cache reference errors for a bundleId.
287
334
  */
@@ -82,6 +82,16 @@ const ACTION_TO_CAPABILITY = {
82
82
  select: "canSelect",
83
83
  scroll: "canScroll",
84
84
  };
85
+ /** Maps sensor sourceType names to ExecutionMethod names */
86
+ const SENSOR_TO_METHOD = {
87
+ ax: "ax",
88
+ accessibility: "ax",
89
+ cdp: "cdp",
90
+ chrome: "cdp",
91
+ ocr: "ocr",
92
+ vision: "ocr",
93
+ coordinates: "coordinates",
94
+ };
85
95
  /**
86
96
  * Given an action type and available capabilities, returns the ordered
87
97
  * list of methods to try.
@@ -89,11 +99,15 @@ const ACTION_TO_CAPABILITY = {
89
99
  * Filters EXECUTION_METHODS to only those that:
90
100
  * 1. Support the requested action
91
101
  * 2. Have their infrastructure requirements met
92
- * Returns in canonical order (ax -> cdp -> ocr -> coordinates).
102
+ *
103
+ * When sensorRanking is provided (from LearningEngine.rankSensors()),
104
+ * reorders methods by learned success scores instead of using the
105
+ * hardcoded canonical order. Methods not present in the ranking
106
+ * are appended at the end in canonical order.
93
107
  */
94
- function planExecution(action, available) {
108
+ function planExecution(action, available, sensorRanking) {
95
109
  const capKey = ACTION_TO_CAPABILITY[action];
96
- return EXECUTION_METHODS.filter((method) => {
110
+ const eligible = EXECUTION_METHODS.filter((method) => {
97
111
  const cap = METHOD_CAPABILITIES[method];
98
112
  // Must support the requested action
99
113
  if (!cap[capKey])
@@ -105,6 +119,42 @@ function planExecution(action, available) {
105
119
  return false;
106
120
  return true;
107
121
  });
122
+ // Without sensor data, return canonical order
123
+ if (!sensorRanking || sensorRanking.length === 0)
124
+ return eligible;
125
+ // Build score map: ExecutionMethod → { score, latency }
126
+ // When multiple sourceTypes alias to the same method (e.g. "vision" + "ocr" → "ocr"),
127
+ // keep the higher score to avoid silent overwrite.
128
+ const scoreMap = new Map();
129
+ for (const entry of sensorRanking) {
130
+ const method = SENSOR_TO_METHOD[entry.sourceType];
131
+ if (method && eligible.includes(method)) {
132
+ const existing = scoreMap.get(method);
133
+ if (!existing || entry.score > existing.score) {
134
+ scoreMap.set(method, { score: entry.score, latencyMs: entry.avgLatencyMs });
135
+ }
136
+ }
137
+ }
138
+ // Sort: ranked methods first (by score desc, latency asc tiebreak),
139
+ // unranked methods last (canonical order preserved by stable sort)
140
+ return eligible.slice().sort((a, b) => {
141
+ const sa = scoreMap.get(a);
142
+ const sb = scoreMap.get(b);
143
+ if (sa != null && sb != null) {
144
+ const scoreDiff = sb.score - sa.score;
145
+ // Mirror SensorPolicy's 0.05 band: treat <5% score gaps as noise,
146
+ // let latency decide. Prevents planExecution from reversing the
147
+ // policy's latency-preferred orderings for near-equal scores.
148
+ if (Math.abs(scoreDiff) > 0.05)
149
+ return scoreDiff; // meaningful score gap → score wins
150
+ return sa.latencyMs - sb.latencyMs; // within noise band → lower latency first
151
+ }
152
+ if (sa != null)
153
+ return -1; // a ranked, b not → a first
154
+ if (sb != null)
155
+ return 1; // b ranked, a not → b first
156
+ return 0; // both unranked → keep canonical order (V8 stable sort)
157
+ });
108
158
  }
109
159
  const DEFAULT_RETRY_POLICY = {
110
160
  maxRetriesPerMethod: 2,
@@ -125,6 +175,17 @@ function delay(ms) {
125
175
  * Returns the result from whichever method succeeded (or the last failure).
126
176
  */
127
177
  async function executeWithFallback(action, plan, policy, executor) {
178
+ if (plan.length === 0) {
179
+ return {
180
+ ok: false,
181
+ method: "ax",
182
+ durationMs: 0,
183
+ fallbackFrom: null,
184
+ retries: 0,
185
+ error: "No execution methods available",
186
+ target: null,
187
+ };
188
+ }
128
189
  let totalRetries = 0;
129
190
  let lastResult = null;
130
191
  let previousMethod = null;
@@ -134,8 +195,8 @@ async function executeWithFallback(action, plan, policy, executor) {
134
195
  // Exhausted total retry budget — return whatever we have
135
196
  return lastResult;
136
197
  }
137
- // Delay between retries (not before the very first attempt)
138
- if (totalRetries > 0) {
198
+ // Delay between retries (not before the very first attempt of this method)
199
+ if (attempt > 0) {
139
200
  await delay(policy.delayBetweenRetriesMs);
140
201
  }
141
202
  const result = await executor(method, attempt);
@@ -147,6 +208,7 @@ async function executeWithFallback(action, plan, policy, executor) {
147
208
  if (result.ok) {
148
209
  return result;
149
210
  }
211
+ // Only count failed attempts toward total retry budget
150
212
  totalRetries++;
151
213
  }
152
214
  // This method is exhausted — record it so the next method knows
@@ -19,16 +19,56 @@ export class Executor {
19
19
  adapter;
20
20
  cache;
21
21
  logger;
22
+ appMap = null;
22
23
  constructor(adapter, cache, logger) {
23
24
  this.adapter = adapter;
24
25
  this.cache = cache;
25
26
  this.logger = logger;
26
27
  }
28
+ /**
29
+ * Wire #15: Set AppMap for skip-verify optimization.
30
+ * BundleId is resolved dynamically per-call from the adapter.
31
+ */
32
+ setAppMap(appMap) {
33
+ this.appMap = appMap;
34
+ }
35
+ /**
36
+ * Wire #15: Check if an element is well-known enough to skip verify.
37
+ * Requires 3+ prior successes and last interaction within 5 minutes.
38
+ * Never skips on retry (retry > 0) — retries need verification.
39
+ */
40
+ shouldSkipVerify(target, bundleId, retry) {
41
+ if (retry > 0)
42
+ return false; // Bug #3 fix: always verify on retry
43
+ if (!this.appMap || !bundleId)
44
+ return false;
45
+ let label = null;
46
+ if (target.type === "text")
47
+ label = target.value;
48
+ else if (target.type === "selector")
49
+ label = target.value;
50
+ else if (target.type === "role")
51
+ label = target.name;
52
+ else if (target.type === "ax_attribute")
53
+ label = `${target.attribute}=${target.value}`;
54
+ else if (target.type === "ax_path")
55
+ label = target.path.join("/");
56
+ if (!label)
57
+ return false;
58
+ return this.appMap.isElementVerified(bundleId, label);
59
+ }
27
60
  async press(input) {
28
61
  const telemetry = this.logger.start("press", input.sessionId);
29
62
  const budget = this.resolveBudget(input.budget);
30
63
  const attempts = [];
31
64
  let lastError;
65
+ // Wire #15: resolve bundleId dynamically for skip-verify
66
+ let pressBundleId = null;
67
+ try {
68
+ const ctx = await this.adapter.getAppContext(input.sessionId);
69
+ pressBundleId = ctx.bundleId ?? null;
70
+ }
71
+ catch { /* non-fatal — skip-verify just won't activate */ }
32
72
  for (let retry = 0; retry <= budget.maxRetries; retry += 1) {
33
73
  telemetry.retries = retry;
34
74
  try {
@@ -49,7 +89,7 @@ export class Executor {
49
89
  await this.adapter.click(input.sessionId, locateResult.element);
50
90
  }, "ACTION_FAILED");
51
91
  telemetry.actMs += budget.actMs;
52
- if (input.verify) {
92
+ if (input.verify && !this.shouldSkipVerify(input.target, pressBundleId, retry)) {
53
93
  const verified = await this.timed(budget.verifyMs, () => this.adapter.waitFor(input.sessionId, input.verify, budget.verifyMs), "VERIFY_FAILED");
54
94
  telemetry.verifyMs += budget.verifyMs;
55
95
  if (!verified) {
@@ -36,6 +36,13 @@ export class AutomationRuntimeService {
36
36
  setWorldModel(model) {
37
37
  this.worldModel = model;
38
38
  }
39
+ /**
40
+ * Wire #15: Pass AppMap to Executor for skip-verify optimization.
41
+ * BundleId is resolved dynamically per-call from the adapter.
42
+ */
43
+ setAppMap(appMap) {
44
+ this.executor.setAppMap(appMap);
45
+ }
39
46
  async sessionStart(profile = DEFAULT_PROFILE) {
40
47
  return this.sessions.sessionStart(profile);
41
48
  }