screenhand 0.5.0 → 0.5.2
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/dist/mcp-desktop.js +280 -39
- package/dist/src/community/publisher.js +4 -2
- package/dist/src/context-tracker.js +36 -6
- package/dist/src/ingestion/reference-merger.js +33 -0
- package/dist/src/memory/recall.js +65 -1
- package/dist/src/memory/research.js +1 -1
- package/dist/src/memory/service.js +26 -5
- package/dist/src/memory/store.js +42 -23
- package/dist/src/native/bridge-client.js +3 -3
- package/dist/src/perception/coordinator.js +62 -15
- package/dist/src/perception/manager.js +65 -1
- package/dist/src/planner/executor.js +6 -2
- package/dist/src/planner/plan-refiner.js +213 -0
- package/dist/src/playbook/engine.js +18 -3
- package/dist/src/playbook/recorder.js +24 -8
- package/dist/src/playbook/runner.js +9 -3
- package/dist/src/playbook/store.js +8 -0
- package/dist/src/recovery/engine.js +9 -3
- package/dist/src/state/app-map.js +6 -2
- package/dist/src/state/state-watcher.js +144 -0
- package/dist/src/supervisor/supervisor.js +1 -1
- package/dist-app-maps/com.apple.iphonesimulator.json +714 -223
- package/dist-references/simulator.json +48 -2
- package/package.json +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// This file is part of ScreenHand.
|
|
5
|
+
//
|
|
6
|
+
// ScreenHand is free software: you can redistribute it and/or modify
|
|
7
|
+
// it under the terms of the GNU Affero General Public License as
|
|
8
|
+
// published by the Free Software Foundation, version 3.
|
|
9
|
+
//
|
|
10
|
+
// ScreenHand is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
/**
|
|
18
|
+
* PlanRefiner — Self-improving plans.
|
|
19
|
+
*
|
|
20
|
+
* After a successful plan execution, diffs the original plan vs what actually
|
|
21
|
+
* happened (steps skipped, fallbacks used, extra retries). Produces a refined
|
|
22
|
+
* plan stripped of dead steps. After 3 successful refinements of the same goal
|
|
23
|
+
* pattern, graduates the plan to a playbook.
|
|
24
|
+
*/
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { writeFileAtomicSync } from "../util/atomic-write.js";
|
|
28
|
+
const GRADUATION_THRESHOLD = 3;
|
|
29
|
+
const MAX_REFINED_PLANS = 200;
|
|
30
|
+
export class PlanRefiner {
|
|
31
|
+
stateDir;
|
|
32
|
+
cache = new Map();
|
|
33
|
+
filePath;
|
|
34
|
+
constructor(stateDir) {
|
|
35
|
+
this.stateDir = stateDir;
|
|
36
|
+
this.filePath = path.join(stateDir, "refined-plans.json");
|
|
37
|
+
this.load();
|
|
38
|
+
}
|
|
39
|
+
load() {
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(this.filePath)) {
|
|
42
|
+
const data = JSON.parse(fs.readFileSync(this.filePath, "utf-8"));
|
|
43
|
+
if (Array.isArray(data)) {
|
|
44
|
+
for (const entry of data) {
|
|
45
|
+
if (entry.goalKey && typeof entry.goalKey === "string") {
|
|
46
|
+
this.cache.set(entry.goalKey, entry);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Corrupted file — start fresh
|
|
54
|
+
this.cache.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
save() {
|
|
58
|
+
try {
|
|
59
|
+
// Evict oldest graduated plans if over limit
|
|
60
|
+
if (this.cache.size > MAX_REFINED_PLANS) {
|
|
61
|
+
const sorted = [...this.cache.entries()]
|
|
62
|
+
.sort((a, b) => new Date(a[1].lastRefinedAt).getTime() - new Date(b[1].lastRefinedAt).getTime());
|
|
63
|
+
while (this.cache.size > MAX_REFINED_PLANS && sorted.length > 0) {
|
|
64
|
+
const oldest = sorted.shift();
|
|
65
|
+
this.cache.delete(oldest[0]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const data = [...this.cache.values()];
|
|
69
|
+
writeFileAtomicSync(this.filePath, JSON.stringify(data, null, 2));
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
process.stderr.write(`[plan-refiner] Save failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
normalizeGoal(description) {
|
|
76
|
+
return description.toLowerCase().trim().replace(/\s+/g, " ");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Refine a plan after successful execution.
|
|
80
|
+
* Extracts only the steps that actually completed (strips skipped/failed).
|
|
81
|
+
* Returns the refinement count and whether the plan was graduated.
|
|
82
|
+
*/
|
|
83
|
+
refine(goal, result) {
|
|
84
|
+
if (!result.success)
|
|
85
|
+
return { refinementCount: 0, graduated: false };
|
|
86
|
+
const goalKey = this.normalizeGoal(goal.description);
|
|
87
|
+
// Extract completed steps from all subgoals
|
|
88
|
+
const completedSteps = [];
|
|
89
|
+
for (const sg of goal.subgoals) {
|
|
90
|
+
if (sg.plan) {
|
|
91
|
+
for (const step of sg.plan.steps) {
|
|
92
|
+
if (step.status === "completed") {
|
|
93
|
+
completedSteps.push({ tool: step.tool, params: step.params });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (completedSteps.length === 0)
|
|
99
|
+
return { refinementCount: 0, graduated: false };
|
|
100
|
+
const existing = this.cache.get(goalKey);
|
|
101
|
+
const refinementCount = (existing?.refinementCount ?? 0) + 1;
|
|
102
|
+
const prevAvg = existing?.avgDurationMs ?? result.durationMs;
|
|
103
|
+
const avgDurationMs = Math.round((prevAvg * (refinementCount - 1) + result.durationMs) / refinementCount);
|
|
104
|
+
const refined = {
|
|
105
|
+
goalKey,
|
|
106
|
+
goalDescription: goal.description,
|
|
107
|
+
steps: completedSteps,
|
|
108
|
+
refinementCount,
|
|
109
|
+
avgDurationMs,
|
|
110
|
+
lastRefinedAt: new Date().toISOString(),
|
|
111
|
+
graduated: existing?.graduated ?? false,
|
|
112
|
+
};
|
|
113
|
+
this.cache.set(goalKey, refined);
|
|
114
|
+
this.save();
|
|
115
|
+
return { refinementCount, graduated: refined.graduated };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Check if a goal should graduate to a playbook.
|
|
119
|
+
* Returns the playbook if ready, null otherwise.
|
|
120
|
+
*/
|
|
121
|
+
checkGraduation(goalDescription, bundleId, appName) {
|
|
122
|
+
const goalKey = this.normalizeGoal(goalDescription);
|
|
123
|
+
const refined = this.cache.get(goalKey);
|
|
124
|
+
if (!refined)
|
|
125
|
+
return null;
|
|
126
|
+
if (refined.graduated)
|
|
127
|
+
return null;
|
|
128
|
+
if (refined.refinementCount < GRADUATION_THRESHOLD)
|
|
129
|
+
return null;
|
|
130
|
+
// Graduate: create a playbook from the refined steps
|
|
131
|
+
const playbookSteps = [];
|
|
132
|
+
for (let i = 0; i < refined.steps.length; i++) {
|
|
133
|
+
const s = refined.steps[i];
|
|
134
|
+
const mapped = mapToolToPlaybookStep(s.tool, s.params, i);
|
|
135
|
+
if (mapped)
|
|
136
|
+
playbookSteps.push(mapped);
|
|
137
|
+
}
|
|
138
|
+
const playbook = {
|
|
139
|
+
id: `graduated_${goalKey.replace(/[^a-z0-9]/g, "_").slice(0, 40)}_${Date.now().toString(36)}`,
|
|
140
|
+
name: refined.goalDescription,
|
|
141
|
+
description: `Auto-graduated from ${refined.refinementCount} successful plan executions (avg ${refined.avgDurationMs}ms)`,
|
|
142
|
+
platform: appName.toLowerCase(),
|
|
143
|
+
bundleId,
|
|
144
|
+
version: "1.0.0",
|
|
145
|
+
steps: playbookSteps,
|
|
146
|
+
tags: [appName.toLowerCase(), "auto-graduated", "refined"],
|
|
147
|
+
successCount: refined.refinementCount,
|
|
148
|
+
failCount: 0,
|
|
149
|
+
selectors: {},
|
|
150
|
+
errors: [],
|
|
151
|
+
};
|
|
152
|
+
// Mark as graduated
|
|
153
|
+
refined.graduated = true;
|
|
154
|
+
this.save();
|
|
155
|
+
return playbook;
|
|
156
|
+
}
|
|
157
|
+
/** Get all refined plans (for debugging/status). */
|
|
158
|
+
getAll() {
|
|
159
|
+
return [...this.cache.values()];
|
|
160
|
+
}
|
|
161
|
+
/** Look up a refined plan for a goal. */
|
|
162
|
+
lookup(goalDescription) {
|
|
163
|
+
return this.cache.get(this.normalizeGoal(goalDescription)) ?? null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Map MCP tool name + params to a PlaybookStep. Returns null if unmappable. */
|
|
167
|
+
function mapToolToPlaybookStep(tool, params, index) {
|
|
168
|
+
const TOOL_TO_ACTION = {
|
|
169
|
+
ui_press: "press",
|
|
170
|
+
click_text: "press",
|
|
171
|
+
click: "press",
|
|
172
|
+
click_with_fallback: "press",
|
|
173
|
+
type_text: "type_into",
|
|
174
|
+
type_with_fallback: "type_into",
|
|
175
|
+
key: "key",
|
|
176
|
+
browser_navigate: "navigate",
|
|
177
|
+
browser_click: "browser_click",
|
|
178
|
+
browser_type: "browser_type",
|
|
179
|
+
browser_human_click: "browser_human_click",
|
|
180
|
+
browser_js: "browser_js",
|
|
181
|
+
menu_click: "menu_click",
|
|
182
|
+
scroll: "scroll",
|
|
183
|
+
scroll_with_fallback: "scroll",
|
|
184
|
+
screenshot: "screenshot",
|
|
185
|
+
applescript: "applescript",
|
|
186
|
+
};
|
|
187
|
+
const action = TOOL_TO_ACTION[tool];
|
|
188
|
+
if (!action)
|
|
189
|
+
return null; // focus, launch, etc. are not playbook-able
|
|
190
|
+
const step = {
|
|
191
|
+
action,
|
|
192
|
+
description: `Step ${index + 1}: ${tool}`,
|
|
193
|
+
};
|
|
194
|
+
// Map params to playbook step fields
|
|
195
|
+
const target = params.title ?? params.text ?? params.selector ?? params.target;
|
|
196
|
+
if (typeof target === "string")
|
|
197
|
+
step.target = target;
|
|
198
|
+
if (typeof params.text === "string")
|
|
199
|
+
step.text = params.text;
|
|
200
|
+
if (typeof params.url === "string")
|
|
201
|
+
step.url = params.url;
|
|
202
|
+
if (typeof params.combo === "string")
|
|
203
|
+
step.keys = [params.combo];
|
|
204
|
+
if (typeof params.key === "string")
|
|
205
|
+
step.keys = [params.key];
|
|
206
|
+
if (Array.isArray(params.menuPath))
|
|
207
|
+
step.menuPath = params.menuPath;
|
|
208
|
+
if (typeof params.direction === "string")
|
|
209
|
+
step.direction = params.direction;
|
|
210
|
+
if (typeof params.amount === "number")
|
|
211
|
+
step.amount = params.amount;
|
|
212
|
+
return step;
|
|
213
|
+
}
|
|
@@ -23,9 +23,15 @@ export class PlaybookEngine {
|
|
|
23
23
|
appleScriptRunner;
|
|
24
24
|
/** Enable observer-based popup checks before each step */
|
|
25
25
|
popupCheckEnabled = false;
|
|
26
|
+
/** Optional callback invoked after each step with outcome — wires into learning/memory. */
|
|
27
|
+
onOutcome;
|
|
26
28
|
constructor(runtime) {
|
|
27
29
|
this.runtime = runtime;
|
|
28
30
|
}
|
|
31
|
+
/** Set callback for step outcomes so PlaybookEngine feeds learning. */
|
|
32
|
+
setOutcomeCallback(cb) {
|
|
33
|
+
this.onOutcome = cb;
|
|
34
|
+
}
|
|
29
35
|
/** Enable/disable pre-step popup detection via observer daemon */
|
|
30
36
|
setPopupCheck(enabled) {
|
|
31
37
|
this.popupCheckEnabled = enabled;
|
|
@@ -61,6 +67,9 @@ export class PlaybookEngine {
|
|
|
61
67
|
}
|
|
62
68
|
const result = await this.executeStep(sessionId, step, playbook.cdpPort);
|
|
63
69
|
stepsCompleted++;
|
|
70
|
+
// Report success to learning pipeline
|
|
71
|
+
if (this.onOutcome)
|
|
72
|
+
this.onOutcome(step, true, null);
|
|
64
73
|
if (options.onStep) {
|
|
65
74
|
options.onStep(i, step, result);
|
|
66
75
|
}
|
|
@@ -68,6 +77,8 @@ export class PlaybookEngine {
|
|
|
68
77
|
if (step.verify) {
|
|
69
78
|
const verified = await this.verifyStep(sessionId, step);
|
|
70
79
|
if (!verified && !step.optional) {
|
|
80
|
+
if (this.onOutcome)
|
|
81
|
+
this.onOutcome(step, false, `Verification failed at step ${i}`);
|
|
71
82
|
return {
|
|
72
83
|
playbook: playbook.id,
|
|
73
84
|
success: false,
|
|
@@ -83,10 +94,14 @@ export class PlaybookEngine {
|
|
|
83
94
|
await sleep(STEP_DELAY_MS);
|
|
84
95
|
}
|
|
85
96
|
catch (err) {
|
|
97
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
// Report failure to learning pipeline
|
|
99
|
+
if (this.onOutcome)
|
|
100
|
+
this.onOutcome(step, false, errMsg);
|
|
86
101
|
if (step.optional) {
|
|
87
102
|
stepsCompleted++;
|
|
88
103
|
if (options.onStep) {
|
|
89
|
-
options.onStep(i, step, `Skipped (optional): ${
|
|
104
|
+
options.onStep(i, step, `Skipped (optional): ${errMsg}`);
|
|
90
105
|
}
|
|
91
106
|
continue;
|
|
92
107
|
}
|
|
@@ -96,7 +111,7 @@ export class PlaybookEngine {
|
|
|
96
111
|
stepsCompleted,
|
|
97
112
|
totalSteps: playbook.steps.length,
|
|
98
113
|
failedAtStep: i,
|
|
99
|
-
error:
|
|
114
|
+
error: errMsg,
|
|
100
115
|
durationMs: Date.now() - start,
|
|
101
116
|
};
|
|
102
117
|
}
|
|
@@ -115,7 +130,7 @@ export class PlaybookEngine {
|
|
|
115
130
|
*/
|
|
116
131
|
/** Tools that the PlaybookEngine refuses to execute (defense in depth). */
|
|
117
132
|
static BLOCKED_ACTIONS = new Set([
|
|
118
|
-
"
|
|
133
|
+
"browser_stealth",
|
|
119
134
|
"memory_save", "memory_clear", "memory_snapshot",
|
|
120
135
|
"supervisor_start", "supervisor_stop", "supervisor_install", "supervisor_uninstall",
|
|
121
136
|
"job_create", "worker_start",
|
|
@@ -98,7 +98,9 @@ export class PlaybookRecorder {
|
|
|
98
98
|
try {
|
|
99
99
|
await this.pollAXState();
|
|
100
100
|
}
|
|
101
|
-
catch {
|
|
101
|
+
catch (e) {
|
|
102
|
+
process.stderr.write(`[recorder] AX poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
103
|
+
}
|
|
102
104
|
}, AX_POLL_INTERVAL_MS);
|
|
103
105
|
// Start screenshot capture (slower — every 2.5s)
|
|
104
106
|
if (this.captureScreenshots) {
|
|
@@ -108,7 +110,9 @@ export class PlaybookRecorder {
|
|
|
108
110
|
try {
|
|
109
111
|
await this.takeScreenshot();
|
|
110
112
|
}
|
|
111
|
-
catch {
|
|
113
|
+
catch (e) {
|
|
114
|
+
process.stderr.write(`[recorder] screenshot capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
115
|
+
}
|
|
112
116
|
}, SCREENSHOT_INTERVAL_MS);
|
|
113
117
|
}
|
|
114
118
|
}
|
|
@@ -123,7 +127,9 @@ export class PlaybookRecorder {
|
|
|
123
127
|
try {
|
|
124
128
|
await this.takeScreenshot();
|
|
125
129
|
}
|
|
126
|
-
catch {
|
|
130
|
+
catch (e) {
|
|
131
|
+
process.stderr.write(`[recorder] final screenshot failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
132
|
+
}
|
|
127
133
|
}
|
|
128
134
|
this.log(`Recording stopped. ${this.events.length} events, ${this.screenshots.length} screenshots captured.`);
|
|
129
135
|
// Convert raw events + screenshots to playbook steps via AI
|
|
@@ -190,7 +196,9 @@ export class PlaybookRecorder {
|
|
|
190
196
|
}
|
|
191
197
|
}
|
|
192
198
|
}
|
|
193
|
-
catch {
|
|
199
|
+
catch (e) {
|
|
200
|
+
process.stderr.write(`[recorder] app list poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
201
|
+
}
|
|
194
202
|
// 2. Get accessibility tree — find focused element and text field values
|
|
195
203
|
try {
|
|
196
204
|
const tree = await this.runtime.elementTree({ sessionId: this.sessionId, maxDepth: 4 });
|
|
@@ -238,7 +246,9 @@ export class PlaybookRecorder {
|
|
|
238
246
|
this.prevWindowTitle = title;
|
|
239
247
|
}
|
|
240
248
|
}
|
|
241
|
-
catch {
|
|
249
|
+
catch (e) {
|
|
250
|
+
process.stderr.write(`[recorder] window title poll failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
251
|
+
}
|
|
242
252
|
}
|
|
243
253
|
// ── Screenshot Capture ──
|
|
244
254
|
async takeScreenshot() {
|
|
@@ -253,7 +263,9 @@ export class PlaybookRecorder {
|
|
|
253
263
|
this.screenshots.push(record);
|
|
254
264
|
}
|
|
255
265
|
}
|
|
256
|
-
catch {
|
|
266
|
+
catch (e) {
|
|
267
|
+
process.stderr.write(`[recorder] takeScreenshot failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
268
|
+
}
|
|
257
269
|
}
|
|
258
270
|
// ── State Capture ──
|
|
259
271
|
async captureState(label) {
|
|
@@ -271,7 +283,9 @@ export class PlaybookRecorder {
|
|
|
271
283
|
}
|
|
272
284
|
}
|
|
273
285
|
}
|
|
274
|
-
catch {
|
|
286
|
+
catch (e) {
|
|
287
|
+
process.stderr.write(`[recorder] initial app state capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
288
|
+
}
|
|
275
289
|
// Capture initial tree state
|
|
276
290
|
try {
|
|
277
291
|
const tree = await this.runtime.elementTree({ sessionId: this.sessionId, maxDepth: 4 });
|
|
@@ -281,7 +295,9 @@ export class PlaybookRecorder {
|
|
|
281
295
|
this.prevTextFields = collectTextFields(tree.data);
|
|
282
296
|
}
|
|
283
297
|
}
|
|
284
|
-
catch {
|
|
298
|
+
catch (e) {
|
|
299
|
+
process.stderr.write(`[recorder] initial tree state capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
300
|
+
}
|
|
285
301
|
// Take initial screenshot
|
|
286
302
|
if (this.captureScreenshots) {
|
|
287
303
|
await this.takeScreenshot();
|
|
@@ -97,7 +97,9 @@ export class PlaybookRunner {
|
|
|
97
97
|
if (shot.ok)
|
|
98
98
|
screenshotInfo = `Screenshot saved to: ${shot.data.path}`;
|
|
99
99
|
}
|
|
100
|
-
catch {
|
|
100
|
+
catch (e) {
|
|
101
|
+
process.stderr.write(`[playbook-runner] screenshot for recovery failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
102
|
+
}
|
|
101
103
|
// Get current page state
|
|
102
104
|
let pageState = "";
|
|
103
105
|
try {
|
|
@@ -106,7 +108,9 @@ export class PlaybookRunner {
|
|
|
106
108
|
pageState = JSON.stringify(tree.data).slice(0, 3000);
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
|
-
catch {
|
|
111
|
+
catch (e) {
|
|
112
|
+
process.stderr.write(`[playbook-runner] elementTree for recovery failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
113
|
+
}
|
|
110
114
|
// Build rich context from playbook metadata
|
|
111
115
|
const playbookContext = buildPlaybookContext(playbook);
|
|
112
116
|
const prompt = `A playbook automation failed. Help me recover.
|
|
@@ -221,7 +225,9 @@ Or if unrecoverable, respond with: { "unrecoverable": true, "reason": "..." }`;
|
|
|
221
225
|
if (tree.ok)
|
|
222
226
|
pageState = JSON.stringify(tree.data).slice(0, 4000);
|
|
223
227
|
}
|
|
224
|
-
catch {
|
|
228
|
+
catch (e) {
|
|
229
|
+
process.stderr.write(`[playbook-runner] elementTree in AI loop failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
230
|
+
}
|
|
225
231
|
const prompt = `Task: ${task}
|
|
226
232
|
|
|
227
233
|
Steps taken so far:
|
|
@@ -63,6 +63,10 @@ export class PlaybookStore {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
/** Reload all playbooks from disk (call after ingestion writes new data). */
|
|
67
|
+
reload() {
|
|
68
|
+
this.load();
|
|
69
|
+
}
|
|
66
70
|
/** Get all loaded playbooks. */
|
|
67
71
|
getAll() {
|
|
68
72
|
return [...this.playbooks.values()];
|
|
@@ -274,6 +278,10 @@ export class PlaybookStore {
|
|
|
274
278
|
if (raw.flows && typeof raw.flows === "object") {
|
|
275
279
|
return this.convertLegacy(raw, filename);
|
|
276
280
|
}
|
|
281
|
+
// Reference format: has selectors but no steps/flows (e.g. *-explore.json from platform_explore)
|
|
282
|
+
if (raw.selectors && typeof raw.selectors === "object") {
|
|
283
|
+
return this.convertLegacy(raw, filename);
|
|
284
|
+
}
|
|
277
285
|
return null;
|
|
278
286
|
}
|
|
279
287
|
/**
|
|
@@ -109,7 +109,9 @@ export class RecoveryEngine {
|
|
|
109
109
|
if (undoStrategy)
|
|
110
110
|
candidates.push(undoStrategy);
|
|
111
111
|
}
|
|
112
|
-
catch {
|
|
112
|
+
catch (e) {
|
|
113
|
+
process.stderr.write(`[recovery] buildUndoStrategy failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
114
|
+
}
|
|
113
115
|
}
|
|
114
116
|
// Reference strategies second (app-specific)
|
|
115
117
|
if (blocker.bundleId) {
|
|
@@ -355,7 +357,9 @@ export class RecoveryEngine {
|
|
|
355
357
|
break;
|
|
356
358
|
}
|
|
357
359
|
}
|
|
358
|
-
catch {
|
|
360
|
+
catch (e) {
|
|
361
|
+
process.stderr.write(`[recovery] malformed reference file: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
362
|
+
}
|
|
359
363
|
}
|
|
360
364
|
}
|
|
361
365
|
catch { /* dir doesn't exist */ }
|
|
@@ -366,7 +370,9 @@ export class RecoveryEngine {
|
|
|
366
370
|
try {
|
|
367
371
|
this.memory.recordError(`recovery:${event.strategyId}`, event.error ?? "", event.success ? event.strategyLabel : null, event.blocker.bundleId ?? undefined);
|
|
368
372
|
}
|
|
369
|
-
catch {
|
|
373
|
+
catch (e) {
|
|
374
|
+
process.stderr.write(`[recovery] recordEvent failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
375
|
+
}
|
|
370
376
|
}
|
|
371
377
|
}
|
|
372
378
|
function sleep(ms) {
|
|
@@ -339,7 +339,9 @@ export class AppMap {
|
|
|
339
339
|
fs.mkdirSync(this.config.mapsDir, { recursive: true });
|
|
340
340
|
writeFileAtomicSync(this.ladderFilePath(bundleId), JSON.stringify(data, null, 2));
|
|
341
341
|
}
|
|
342
|
-
catch {
|
|
342
|
+
catch (e) {
|
|
343
|
+
process.stderr.write(`[app-map] saveGeneratedLadder failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
344
|
+
}
|
|
343
345
|
}
|
|
344
346
|
// ── Create ────────────────────────────────────────────────────────
|
|
345
347
|
createEmpty(bundleId, appName, version = "unknown") {
|
|
@@ -1591,7 +1593,9 @@ export class AppMap {
|
|
|
1591
1593
|
writeFileAtomicSync(this.filePath(data.app), JSON.stringify(data, null, 2) + "\n");
|
|
1592
1594
|
this.dirty.delete(data.app); // Only remove the one we just wrote
|
|
1593
1595
|
}
|
|
1594
|
-
catch {
|
|
1596
|
+
catch (e) {
|
|
1597
|
+
process.stderr.write(`[app-map] urgent mastery save failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1598
|
+
}
|
|
1595
1599
|
}
|
|
1596
1600
|
}
|
|
1597
1601
|
refreshMastery(bundleId) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Copyright (C) 2025 Clazro Technology Private Limited
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
//
|
|
4
|
+
// This file is part of ScreenHand.
|
|
5
|
+
//
|
|
6
|
+
// ScreenHand is free software: you can redistribute it and/or modify
|
|
7
|
+
// it under the terms of the GNU Affero General Public License as
|
|
8
|
+
// published by the Free Software Foundation, version 3.
|
|
9
|
+
//
|
|
10
|
+
// ScreenHand is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with ScreenHand. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
export class StateWatcher {
|
|
18
|
+
worldModel;
|
|
19
|
+
execute;
|
|
20
|
+
rules = new Map();
|
|
21
|
+
interval = null;
|
|
22
|
+
pollMs;
|
|
23
|
+
constructor(worldModel, execute, pollMs = 2_000) {
|
|
24
|
+
this.worldModel = worldModel;
|
|
25
|
+
this.execute = execute;
|
|
26
|
+
this.pollMs = pollMs;
|
|
27
|
+
}
|
|
28
|
+
static MAX_RULES = 50;
|
|
29
|
+
/**
|
|
30
|
+
* Register a watch rule. Returns the rule ID for later removal.
|
|
31
|
+
*/
|
|
32
|
+
register(rule) {
|
|
33
|
+
if (this.rules.size >= StateWatcher.MAX_RULES && !this.rules.has(rule.id)) {
|
|
34
|
+
throw new Error(`Maximum watch rules (${StateWatcher.MAX_RULES}) reached. Remove existing rules first.`);
|
|
35
|
+
}
|
|
36
|
+
this.rules.set(rule.id, {
|
|
37
|
+
rule,
|
|
38
|
+
fireCount: 0,
|
|
39
|
+
lastFiredAt: 0,
|
|
40
|
+
});
|
|
41
|
+
return rule.id;
|
|
42
|
+
}
|
|
43
|
+
/** Register a convenience rule: fire action when a control with matching title appears. */
|
|
44
|
+
watchForElement(id, elementTitle, action, bundleId) {
|
|
45
|
+
const titleLower = elementTitle.toLowerCase();
|
|
46
|
+
return this.register({
|
|
47
|
+
id,
|
|
48
|
+
description: `Watch for element "${elementTitle}"`,
|
|
49
|
+
condition: (state) => {
|
|
50
|
+
for (const win of state.windows.values()) {
|
|
51
|
+
for (const ctrl of win.controls.values()) {
|
|
52
|
+
if (ctrl.label?.value?.toLowerCase().includes(titleLower)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
},
|
|
59
|
+
action,
|
|
60
|
+
maxFires: 1,
|
|
61
|
+
cooldownMs: 10_000,
|
|
62
|
+
...(bundleId ? { bundleId } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Register: fire action when a dialog appears with matching text. */
|
|
66
|
+
watchForDialog(id, titlePattern, action) {
|
|
67
|
+
return this.register({
|
|
68
|
+
id,
|
|
69
|
+
description: `Watch for dialog matching ${titlePattern}`,
|
|
70
|
+
condition: (state) => state.activeDialogs.some((d) => titlePattern.test(d.title ?? "")),
|
|
71
|
+
action,
|
|
72
|
+
maxFires: 0, // unlimited — dialogs can recur
|
|
73
|
+
cooldownMs: 5_000,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Remove a watch rule by ID. */
|
|
77
|
+
unregister(id) {
|
|
78
|
+
return this.rules.delete(id);
|
|
79
|
+
}
|
|
80
|
+
/** Remove all rules. */
|
|
81
|
+
clear() {
|
|
82
|
+
this.rules.clear();
|
|
83
|
+
}
|
|
84
|
+
/** Get all registered rules. */
|
|
85
|
+
getRules() {
|
|
86
|
+
return [...this.rules.values()].map((rs) => ({
|
|
87
|
+
id: rs.rule.id,
|
|
88
|
+
description: rs.rule.description,
|
|
89
|
+
fireCount: rs.fireCount,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
/** Start the polling loop. */
|
|
93
|
+
start() {
|
|
94
|
+
if (this.interval)
|
|
95
|
+
return;
|
|
96
|
+
this.interval = setInterval(() => {
|
|
97
|
+
void this.tick();
|
|
98
|
+
}, this.pollMs);
|
|
99
|
+
}
|
|
100
|
+
/** Stop the polling loop. */
|
|
101
|
+
stop() {
|
|
102
|
+
if (this.interval) {
|
|
103
|
+
clearInterval(this.interval);
|
|
104
|
+
this.interval = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
get isRunning() {
|
|
108
|
+
return this.interval !== null;
|
|
109
|
+
}
|
|
110
|
+
async tick() {
|
|
111
|
+
const state = this.worldModel.getState();
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const focusedBundleId = state.focusedApp?.bundleId;
|
|
114
|
+
for (const [id, rs] of this.rules) {
|
|
115
|
+
// Max fires check
|
|
116
|
+
if (rs.rule.maxFires > 0 && rs.fireCount >= rs.rule.maxFires)
|
|
117
|
+
continue;
|
|
118
|
+
// Cooldown check
|
|
119
|
+
if (now - rs.lastFiredAt < rs.rule.cooldownMs)
|
|
120
|
+
continue;
|
|
121
|
+
// BundleId filter
|
|
122
|
+
if (rs.rule.bundleId && rs.rule.bundleId !== focusedBundleId)
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
if (rs.rule.condition(state)) {
|
|
126
|
+
rs.fireCount++;
|
|
127
|
+
rs.lastFiredAt = now;
|
|
128
|
+
process.stderr.write(`[state-watcher] Rule "${id}" fired (${rs.fireCount}x): ${rs.rule.description}\n`);
|
|
129
|
+
// Fire and forget — don't block the poll loop
|
|
130
|
+
this.execute(rs.rule.action.tool, rs.rule.action.params).catch((err) => {
|
|
131
|
+
process.stderr.write(`[state-watcher] Rule "${id}" action failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
132
|
+
});
|
|
133
|
+
// Remove exhausted rules
|
|
134
|
+
if (rs.rule.maxFires > 0 && rs.fireCount >= rs.rule.maxFires) {
|
|
135
|
+
this.rules.delete(id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
process.stderr.write(`[state-watcher] Rule "${id}" condition threw: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -297,7 +297,7 @@ export class SessionSupervisor {
|
|
|
297
297
|
this.log(`Poll error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${err instanceof Error ? err.message : String(err)}`);
|
|
298
298
|
if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) {
|
|
299
299
|
this.log("Max consecutive errors reached — stopping supervisor");
|
|
300
|
-
this.stop().catch(() => { });
|
|
300
|
+
this.stop().catch((e) => { process.stderr.write(`[supervisor] stop after max errors failed: ${e instanceof Error ? e.message : String(e)}\n`); });
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
}
|