screenhand 0.3.2 → 0.3.3
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/README.md +2 -2
- package/dist/mcp-desktop.js +490 -96
- package/dist/src/community/fetcher.js +32 -2
- package/dist/src/community/validator.js +15 -1
- package/dist/src/context-tracker.js +115 -43
- package/dist/src/ingestion/reference-merger.js +3 -1
- package/dist/src/learning/engine.js +225 -7
- package/dist/src/learning/locator-policy.js +16 -0
- package/dist/src/learning/pattern-policy.js +9 -0
- package/dist/src/learning/recovery-policy.js +16 -0
- package/dist/src/learning/sensor-policy.js +9 -0
- package/dist/src/learning/timing-model.js +62 -0
- package/dist/src/memory/research.js +7 -1
- package/dist/src/memory/store.js +18 -7
- package/dist/src/perception/coordinator.js +304 -4
- package/dist/src/perception/manager.js +13 -0
- package/dist/src/perception/vision-source.js +14 -4
- package/dist/src/planner/executor.js +125 -2
- package/dist/src/planner/planner.js +509 -10
- package/dist/src/playbook/engine.js +10 -0
- package/dist/src/recovery/engine.js +50 -3
- package/dist/src/runtime/execution-contract.js +67 -5
- package/dist/src/runtime/executor.js +41 -1
- package/dist/src/runtime/service.js +7 -0
- package/dist/src/state/app-map.js +307 -17
- package/dist/src/util/atomic-write.js +25 -4
- package/dist-references/reddit.json +2 -2
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|