screenhand 0.2.0 → 0.3.1

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 (212) hide show
  1. package/README.md +165 -446
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +3615 -400
  4. package/dist/scripts/export-help-center.js +112 -0
  5. package/dist/scripts/marketing-loop.js +117 -0
  6. package/dist/scripts/observer-daemon.js +288 -0
  7. package/dist/scripts/orchestrator-daemon.js +399 -0
  8. package/dist/scripts/threads-campaign.js +208 -0
  9. package/dist/src/community/fetcher.js +109 -0
  10. package/dist/src/community/index.js +6 -0
  11. package/dist/src/community/publisher.js +191 -0
  12. package/dist/src/community/remote-api.js +121 -0
  13. package/dist/src/community/types.js +3 -0
  14. package/dist/src/community/validator.js +95 -0
  15. package/dist/src/context-tracker.js +489 -0
  16. package/dist/src/ingestion/coverage-auditor.js +233 -0
  17. package/dist/src/ingestion/doc-parser.js +164 -0
  18. package/dist/src/ingestion/index.js +8 -0
  19. package/dist/src/ingestion/menu-scanner.js +152 -0
  20. package/dist/src/ingestion/reference-merger.js +186 -0
  21. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  22. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  23. package/dist/src/ingestion/types.js +3 -0
  24. package/dist/src/jobs/manager.js +82 -14
  25. package/dist/src/jobs/runner.js +138 -15
  26. package/dist/src/learning/engine.js +356 -0
  27. package/dist/src/learning/index.js +9 -0
  28. package/dist/src/learning/locator-policy.js +120 -0
  29. package/dist/src/learning/pattern-policy.js +89 -0
  30. package/dist/src/learning/recovery-policy.js +116 -0
  31. package/dist/src/learning/sensor-policy.js +115 -0
  32. package/dist/src/learning/timing-model.js +204 -0
  33. package/dist/src/learning/topology-policy.js +90 -0
  34. package/dist/src/learning/types.js +9 -0
  35. package/dist/src/logging/timeline-logger.js +4 -1
  36. package/dist/src/memory/playbook-seeds.js +200 -0
  37. package/dist/src/memory/recall.js +60 -8
  38. package/dist/src/memory/service.js +30 -5
  39. package/dist/src/memory/store.js +34 -5
  40. package/dist/src/native/bridge-client.js +253 -31
  41. package/dist/src/observer/state.js +199 -0
  42. package/dist/src/observer/types.js +43 -0
  43. package/dist/src/orchestrator/state.js +68 -0
  44. package/dist/src/orchestrator/types.js +22 -0
  45. package/dist/src/perception/ax-source.js +162 -0
  46. package/dist/src/perception/cdp-source.js +162 -0
  47. package/dist/src/perception/coordinator.js +771 -0
  48. package/dist/src/perception/frame-differ.js +287 -0
  49. package/dist/src/perception/index.js +22 -0
  50. package/dist/src/perception/manager.js +199 -0
  51. package/dist/src/perception/types.js +47 -0
  52. package/dist/src/perception/vision-source.js +399 -0
  53. package/dist/src/planner/deterministic.js +298 -0
  54. package/dist/src/planner/executor.js +870 -0
  55. package/dist/src/planner/goal-store.js +92 -0
  56. package/dist/src/planner/index.js +21 -0
  57. package/dist/src/planner/planner.js +520 -0
  58. package/dist/src/planner/tool-registry.js +71 -0
  59. package/dist/src/planner/types.js +22 -0
  60. package/dist/src/platform/explorer.js +213 -0
  61. package/dist/src/platform/help-center-markdown.js +527 -0
  62. package/dist/src/platform/learner.js +257 -0
  63. package/dist/src/playbook/engine.js +296 -11
  64. package/dist/src/playbook/mcp-recorder.js +204 -0
  65. package/dist/src/playbook/recorder.js +3 -2
  66. package/dist/src/playbook/runner.js +1 -1
  67. package/dist/src/playbook/store.js +139 -10
  68. package/dist/src/recovery/detectors.js +156 -0
  69. package/dist/src/recovery/engine.js +327 -0
  70. package/dist/src/recovery/index.js +20 -0
  71. package/dist/src/recovery/strategies.js +274 -0
  72. package/dist/src/recovery/types.js +20 -0
  73. package/dist/src/runtime/accessibility-adapter.js +55 -18
  74. package/dist/src/runtime/applescript-adapter.js +8 -2
  75. package/dist/src/runtime/cdp-chrome-adapter.js +1 -1
  76. package/dist/src/runtime/executor.js +23 -3
  77. package/dist/src/runtime/locator-cache.js +24 -2
  78. package/dist/src/runtime/service.js +59 -15
  79. package/dist/src/runtime/session-manager.js +4 -1
  80. package/dist/src/runtime/vision-adapter.js +2 -1
  81. package/dist/src/state/app-map-types.js +72 -0
  82. package/dist/src/state/app-map.js +1974 -0
  83. package/dist/src/state/entity-tracker.js +108 -0
  84. package/dist/src/state/fusion.js +96 -0
  85. package/dist/src/state/index.js +21 -0
  86. package/dist/src/state/ladder-generator.js +236 -0
  87. package/dist/src/state/persistence.js +156 -0
  88. package/dist/src/state/types.js +17 -0
  89. package/dist/src/state/world-model.js +1456 -0
  90. package/dist/src/util/atomic-write.js +19 -4
  91. package/dist/src/util/sanitize.js +146 -0
  92. package/dist-app-maps/com.figma.Desktop.json +959 -0
  93. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  94. package/dist-app-maps/notion.id.json +2831 -0
  95. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  96. package/dist-playbooks/codex-desktop.json +76 -0
  97. package/dist-playbooks/competitor-research-stack.json +122 -0
  98. package/dist-playbooks/davinci-color-grade.json +153 -0
  99. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  100. package/dist-playbooks/davinci-render.json +114 -0
  101. package/dist-playbooks/devto.json +52 -0
  102. package/dist-playbooks/discord.json +41 -0
  103. package/dist-playbooks/google-flow-create-project.json +59 -0
  104. package/dist-playbooks/google-flow-edit-image.json +90 -0
  105. package/dist-playbooks/google-flow-edit-video.json +90 -0
  106. package/dist-playbooks/google-flow-generate-image.json +68 -0
  107. package/dist-playbooks/google-flow-generate-video.json +191 -0
  108. package/dist-playbooks/google-flow-open-project.json +48 -0
  109. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  110. package/dist-playbooks/google-flow-search-assets.json +64 -0
  111. package/dist-playbooks/instagram.json +57 -0
  112. package/dist-playbooks/linkedin.json +52 -0
  113. package/dist-playbooks/n8n.json +43 -0
  114. package/dist-playbooks/reddit.json +52 -0
  115. package/dist-playbooks/threads.json +59 -0
  116. package/dist-playbooks/x-twitter.json +59 -0
  117. package/dist-playbooks/youtube.json +59 -0
  118. package/dist-references/canva.json +646 -0
  119. package/dist-references/codex-desktop.json +305 -0
  120. package/dist-references/davinci-resolve-keyboard.json +594 -0
  121. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  122. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  123. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  124. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  125. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  126. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  127. package/dist-references/devpost.json +186 -0
  128. package/dist-references/devto.json +317 -0
  129. package/dist-references/discord.json +549 -0
  130. package/dist-references/figma.json +1186 -0
  131. package/dist-references/finder.json +146 -0
  132. package/dist-references/google-ads-transparency.json +95 -0
  133. package/dist-references/google-flow.json +649 -0
  134. package/dist-references/instagram.json +341 -0
  135. package/dist-references/linkedin.json +324 -0
  136. package/dist-references/meta-ad-library.json +86 -0
  137. package/dist-references/n8n.json +387 -0
  138. package/dist-references/notes.json +27 -0
  139. package/dist-references/notion.json +163 -0
  140. package/dist-references/reddit.json +341 -0
  141. package/dist-references/threads.json +337 -0
  142. package/dist-references/x-twitter.json +403 -0
  143. package/dist-references/youtube.json +373 -0
  144. package/native/macos-bridge/Package.swift +22 -0
  145. package/native/macos-bridge/Sources/AccessibilityBridge.swift +482 -0
  146. package/native/macos-bridge/Sources/AppManagement.swift +339 -0
  147. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +537 -0
  148. package/native/macos-bridge/Sources/ObserverBridge.swift +120 -0
  149. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  150. package/native/macos-bridge/Sources/VisionBridge.swift +238 -0
  151. package/native/macos-bridge/Sources/main.swift +498 -0
  152. package/native/windows-bridge/AppManagement.cs +234 -0
  153. package/native/windows-bridge/InputBridge.cs +436 -0
  154. package/native/windows-bridge/Program.cs +270 -0
  155. package/native/windows-bridge/ScreenCapture.cs +453 -0
  156. package/native/windows-bridge/UIAutomationBridge.cs +571 -0
  157. package/native/windows-bridge/WindowsBridge.csproj +17 -0
  158. package/package.json +12 -1
  159. package/scripts/postinstall.cjs +127 -0
  160. package/dist/.audit-log.jsonl +0 -55
  161. package/dist/.screenhand/memory/.lock +0 -1
  162. package/dist/.screenhand/memory/actions.jsonl +0 -85
  163. package/dist/.screenhand/memory/errors.jsonl +0 -5
  164. package/dist/.screenhand/memory/errors.jsonl.bak +0 -4
  165. package/dist/.screenhand/memory/state.json +0 -35
  166. package/dist/.screenhand/memory/state.json.bak +0 -35
  167. package/dist/.screenhand/memory/strategies.jsonl +0 -12
  168. package/dist/agent/cli.js +0 -73
  169. package/dist/agent/loop.js +0 -258
  170. package/dist/config.js +0 -9
  171. package/dist/index.js +0 -56
  172. package/dist/logging/timeline-logger.js +0 -29
  173. package/dist/mcp/mcp-stdio-server.js +0 -448
  174. package/dist/mcp/server.js +0 -347
  175. package/dist/mcp-entry.js +0 -59
  176. package/dist/memory/recall.js +0 -160
  177. package/dist/memory/research.js +0 -98
  178. package/dist/memory/seeds.js +0 -89
  179. package/dist/memory/session.js +0 -161
  180. package/dist/memory/store.js +0 -391
  181. package/dist/memory/types.js +0 -4
  182. package/dist/monitor/codex-monitor.js +0 -377
  183. package/dist/monitor/task-queue.js +0 -84
  184. package/dist/monitor/types.js +0 -49
  185. package/dist/native/bridge-client.js +0 -174
  186. package/dist/native/macos-bridge-client.js +0 -5
  187. package/dist/npm-publish-helper.js +0 -117
  188. package/dist/npm-token-cdp.js +0 -113
  189. package/dist/npm-token-create.js +0 -135
  190. package/dist/npm-token-finish.js +0 -126
  191. package/dist/playbook/engine.js +0 -193
  192. package/dist/playbook/index.js +0 -4
  193. package/dist/playbook/recorder.js +0 -519
  194. package/dist/playbook/runner.js +0 -392
  195. package/dist/playbook/store.js +0 -166
  196. package/dist/playbook/types.js +0 -4
  197. package/dist/runtime/accessibility-adapter.js +0 -377
  198. package/dist/runtime/app-adapter.js +0 -48
  199. package/dist/runtime/applescript-adapter.js +0 -283
  200. package/dist/runtime/ax-role-map.js +0 -80
  201. package/dist/runtime/browser-adapter.js +0 -36
  202. package/dist/runtime/cdp-chrome-adapter.js +0 -505
  203. package/dist/runtime/composite-adapter.js +0 -205
  204. package/dist/runtime/executor.js +0 -250
  205. package/dist/runtime/locator-cache.js +0 -12
  206. package/dist/runtime/planning-loop.js +0 -47
  207. package/dist/runtime/service.js +0 -372
  208. package/dist/runtime/session-manager.js +0 -28
  209. package/dist/runtime/state-observer.js +0 -105
  210. package/dist/runtime/vision-adapter.js +0 -208
  211. package/dist/test-mcp-protocol.js +0 -138
  212. package/dist/types.js +0 -1
@@ -0,0 +1,204 @@
1
+ // Copyright (C) 2025 Clazro Technology Private Limited
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ /**
4
+ * McpPlaybookRecorder — captures MCP tool calls as PlaybookSteps.
5
+ *
6
+ * Start recording → agent does the flow → stop → saves as executable playbook.
7
+ * Like a macro recorder, but for AI tool calls.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { writeFileAtomicSync } from "../util/atomic-write.js";
12
+ import { redactPII } from "../util/sanitize.js";
13
+ /** Tools that are observation-only — not recorded as steps */
14
+ const SKIP_TOOLS = new Set([
15
+ "screenshot", "screenshot_file",
16
+ "ui_tree", "ui_find", "browser_dom", "browser_page_info", "browser_tabs",
17
+ "ocr", "apps", "windows", "memory_recall", "memory_save", "memory_snapshot",
18
+ "memory_stats", "memory_errors", "memory_query_patterns", "memory_record_error",
19
+ "memory_record_learning", "memory_clear", "platform_guide", "playbook_preflight",
20
+ "export_playbook", "playbook_record", "job_create", "job_status", "job_list",
21
+ "job_run", "job_run_all", "job_create_chain", "job_remove", "job_transition",
22
+ "job_step_done", "job_step_fail", "job_resume", "job_dequeue",
23
+ "worker_start", "worker_stop", "worker_status",
24
+ "supervisor_status", "supervisor_start", "supervisor_stop",
25
+ "supervisor_pause", "supervisor_resume", "supervisor_install", "supervisor_uninstall",
26
+ "session_claim", "session_heartbeat", "session_release",
27
+ "recovery_queue_add", "recovery_queue_list",
28
+ "codex_monitor_start", "codex_monitor_status", "codex_monitor_add_task",
29
+ "codex_monitor_tasks", "codex_monitor_assign_now", "codex_monitor_stop",
30
+ "platform_learn", "platform_explore",
31
+ ]);
32
+ /** Map MCP tool names to PlaybookStep actions */
33
+ function mapToolToAction(toolName) {
34
+ switch (toolName) {
35
+ case "browser_navigate": return "navigate";
36
+ case "click":
37
+ case "click_text":
38
+ case "browser_click":
39
+ case "click_with_fallback":
40
+ case "ui_press": return "press";
41
+ case "type_text":
42
+ case "browser_type":
43
+ case "type_with_fallback": return "type_into";
44
+ case "key": return "key";
45
+ case "menu_click": return "menu_click";
46
+ case "scroll":
47
+ case "scroll_with_fallback": return "scroll";
48
+ case "browser_js": return "browser_js";
49
+ case "screenshot":
50
+ case "screenshot_file": return "screenshot";
51
+ case "browser_wait":
52
+ case "wait_for_state": return "wait";
53
+ case "focus":
54
+ case "launch": return null; // useful context but not a step
55
+ case "drag": return null; // drag is complex, skip for now
56
+ default: return null;
57
+ }
58
+ }
59
+ /** Build a PlaybookStep from an MCP tool call */
60
+ function buildStep(toolName, params, success) {
61
+ const action = mapToolToAction(toolName);
62
+ if (!action)
63
+ return null;
64
+ const step = { action };
65
+ switch (action) {
66
+ case "navigate":
67
+ step.url = String(params.url ?? "");
68
+ step.description = `Navigate to ${step.url}`;
69
+ break;
70
+ case "press":
71
+ step.target = String(params.selector ?? params.text ?? params.title ?? params.target ?? "");
72
+ step.description = `Click ${step.target}`;
73
+ break;
74
+ case "type_into":
75
+ step.target = String(params.selector ?? params.target ?? params.field ?? "");
76
+ step.text = String(params.text ?? params.value ?? "");
77
+ step.description = `Type "${step.text.substring(0, 50)}" into ${step.target}`;
78
+ break;
79
+ case "key":
80
+ case "key_combo": {
81
+ const combo = String(params.combo ?? params.key ?? "");
82
+ step.keys = combo.split("+").map(k => k.trim());
83
+ step.description = `${action === "key" ? "Key" : "Key combo"}: ${combo}`;
84
+ break;
85
+ }
86
+ case "menu_click": {
87
+ const menuPath = Array.isArray(params.menuPath)
88
+ ? params.menuPath.map((part) => String(part).trim()).filter(Boolean)
89
+ : String(params.menuPath ?? "").split("/").map((part) => part.trim()).filter(Boolean);
90
+ step.menuPath = menuPath;
91
+ step.description = `Menu click: ${menuPath.join(" > ")}`;
92
+ break;
93
+ }
94
+ case "scroll":
95
+ step.direction = params.direction ?? "down";
96
+ if (params.amount != null)
97
+ step.amount = Number(params.amount);
98
+ step.description = `Scroll ${step.direction}`;
99
+ break;
100
+ case "browser_js":
101
+ step.code = String(params.code ?? "");
102
+ step.description = `Execute JS: ${step.code.substring(0, 60)}...`;
103
+ break;
104
+ case "screenshot":
105
+ step.description = "Take screenshot";
106
+ break;
107
+ case "wait":
108
+ step.ms = Number(params.timeout ?? params.ms ?? params.timeoutMs ?? 1000);
109
+ step.description = `Wait ${step.ms}ms`;
110
+ break;
111
+ }
112
+ if (!success) {
113
+ step.optional = true;
114
+ }
115
+ return step;
116
+ }
117
+ export class McpPlaybookRecorder {
118
+ playbooksDir;
119
+ recording = false;
120
+ platform = "";
121
+ steps = [];
122
+ startTime = "";
123
+ cdpPort;
124
+ constructor(playbooksDir) {
125
+ this.playbooksDir = playbooksDir;
126
+ }
127
+ get isRecording() { return this.recording; }
128
+ get stepCount() { return this.steps.length; }
129
+ getSteps() { return [...this.steps]; }
130
+ start(platform, cdpPort) {
131
+ this.recording = true;
132
+ this.platform = platform;
133
+ this.steps = [];
134
+ this.startTime = new Date().toISOString();
135
+ if (cdpPort !== undefined)
136
+ this.cdpPort = cdpPort;
137
+ }
138
+ captureToolCall(toolName, params, success, _result, _durationMs) {
139
+ if (!this.recording)
140
+ return;
141
+ if (SKIP_TOOLS.has(toolName))
142
+ return;
143
+ const step = buildStep(toolName, params, success);
144
+ if (!step)
145
+ return;
146
+ // Deduplicate consecutive identical steps
147
+ const last = this.steps[this.steps.length - 1];
148
+ if (last &&
149
+ last.action === step.action &&
150
+ last.target === step.target &&
151
+ last.text === step.text &&
152
+ last.code === step.code &&
153
+ JSON.stringify(last.keys ?? []) === JSON.stringify(step.keys ?? []) &&
154
+ JSON.stringify(last.menuPath ?? []) === JSON.stringify(step.menuPath ?? [])) {
155
+ return; // skip duplicate
156
+ }
157
+ this.steps.push(step);
158
+ }
159
+ stop(name, description) {
160
+ this.recording = false;
161
+ // Sanitize platform to prevent path traversal in filename
162
+ const safePlatform = this.platform.replace(/[^a-zA-Z0-9_\-]/g, "_").slice(0, 100);
163
+ const id = safePlatform + "-" + Date.now().toString(36);
164
+ // S75 Option C: Redact PII in persisted playbook steps and metadata
165
+ const redactedSteps = this.steps.map(s => ({
166
+ ...s,
167
+ ...(s.text ? { text: redactPII(s.text) } : {}),
168
+ ...(typeof s.target === "string" ? { target: redactPII(s.target) } : {}),
169
+ ...(s.url ? { url: redactPII(s.url) } : {}),
170
+ ...(s.code ? { code: redactPII(s.code) } : {}),
171
+ ...(s.description ? { description: redactPII(s.description) } : {}),
172
+ }));
173
+ const playbook = {
174
+ id,
175
+ name: redactPII(name),
176
+ description: redactPII(description),
177
+ platform: this.platform,
178
+ version: "1.0.0",
179
+ tags: [this.platform, "recorded"],
180
+ successCount: 0,
181
+ failCount: 0,
182
+ steps: redactedSteps,
183
+ };
184
+ if (this.cdpPort) {
185
+ playbook.cdpPort = this.cdpPort;
186
+ }
187
+ // Save to playbooks dir
188
+ if (!fs.existsSync(this.playbooksDir)) {
189
+ fs.mkdirSync(this.playbooksDir, { recursive: true });
190
+ }
191
+ const safeFilename = `${id}.json`;
192
+ const resolved = path.resolve(path.join(this.playbooksDir, safeFilename));
193
+ if (!resolved.startsWith(path.resolve(this.playbooksDir))) {
194
+ throw new Error("Invalid playbook path — refusing to write outside playbooks directory");
195
+ }
196
+ writeFileAtomicSync(resolved, JSON.stringify(playbook, null, 2));
197
+ this.steps = [];
198
+ return playbook;
199
+ }
200
+ cancel() {
201
+ this.recording = false;
202
+ this.steps = [];
203
+ }
204
+ }
@@ -332,11 +332,12 @@ ${events.map((e, i) => `${i + 1}. [${e.timestamp}] ${e.type}: ${JSON.stringify(e
332
332
  ${screenshots.length > 0 ? `\n${screenshots.length} screenshots were taken during recording. The first and last are attached below for visual context.\n` : ""}
333
333
  Convert these into a JSON array of playbook steps. Each step:
334
334
  {
335
- "action": "navigate" | "press" | "type_into" | "key_combo" | "scroll" | "wait" | "screenshot",
335
+ "action": "navigate" | "press" | "type_into" | "key" | "key_combo" | "menu_click" | "scroll" | "wait" | "screenshot",
336
336
  "target": "CSS selector, text label, or {\"selector\": \"...\"}",
337
337
  "url": "for navigate",
338
338
  "text": "for type_into",
339
- "keys": ["for", "key_combo"],
339
+ "keys": ["for", "key or key_combo"],
340
+ "menuPath": ["for", "menu_click"],
340
341
  "ms": 1000,
341
342
  "description": "human-readable description of what this step does",
342
343
  "verify": "optional CSS selector or text to verify success",
@@ -231,7 +231,7 @@ Current UI state:
231
231
  ${pageState}
232
232
  ${playbookContext ? `\n--- PLAYBOOK REFERENCE ---\n${playbookContext}\nUse the selectors, flows, and error solutions above to guide your actions. Prefer data-testid selectors over text matching.\n---\n` : ""}
233
233
  What's the next step? Respond with ONE step as JSON:
234
- { "action": "press|type_into|navigate|key_combo|scroll|wait", "target": "...", "text": "...", "url": "...", "keys": [...], "ms": 1000, "description": "..." }
234
+ { "action": "press|type_into|navigate|key|key_combo|menu_click|scroll|wait", "target": "...", "text": "...", "url": "...", "keys": [...], "menuPath": ["File", "Save"], "ms": 1000, "description": "..." }
235
235
 
236
236
  Or if done: { "action": "done", "description": "Task complete" }`;
237
237
  try {
@@ -24,6 +24,20 @@
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { writeFileAtomicSync } from "../util/atomic-write.js";
27
+ /** Extract hostname from a URL or URL pattern string */
28
+ function extractHost(urlOrPattern) {
29
+ const cleaned = urlOrPattern.replace(/^\*/, "").replace(/\\/g, "");
30
+ try {
31
+ // Try parsing as a URL
32
+ const u = new URL(cleaned.startsWith("http") ? cleaned : "https://" + cleaned);
33
+ return u.hostname.toLowerCase();
34
+ }
35
+ catch {
36
+ // Fallback: extract domain-like portion
37
+ const match = cleaned.match(/(?:https?:\/\/)?([a-z0-9.-]+)/i);
38
+ return match ? match[1].toLowerCase() : "";
39
+ }
40
+ }
27
41
  export class PlaybookStore {
28
42
  dir;
29
43
  playbooks = new Map();
@@ -57,6 +71,84 @@ export class PlaybookStore {
57
71
  get(id) {
58
72
  return this.playbooks.get(id);
59
73
  }
74
+ /** Find best playbook matching a domain (e.g., "x.com", "figma.com"). */
75
+ matchByDomain(domain) {
76
+ const domainLower = domain.toLowerCase();
77
+ if (domainLower.length === 0)
78
+ return null;
79
+ // Extract the meaningful part of the domain (before first dot)
80
+ const domainPrefix = domainLower.split(".")[0];
81
+ let best = null;
82
+ let bestScore = 0;
83
+ for (const p of this.playbooks.values()) {
84
+ let score = 0;
85
+ // Check platform name — require prefix to be at least 3 chars to avoid phantom matches
86
+ if (domainPrefix.length >= 3 && p.platform.toLowerCase().includes(domainPrefix))
87
+ score += 2;
88
+ // Check URL patterns — extract hostname and compare exactly (no subdomain matching)
89
+ // e.g. "google.com" should NOT match "adstransparency.google.com"
90
+ if (p.urlPatterns?.some((pat) => {
91
+ const host = extractHost(pat);
92
+ return host === domainLower || host === "www." + domainLower;
93
+ }))
94
+ score += 3;
95
+ // Check URLs map — extract hostname and compare exactly
96
+ if (p.urls) {
97
+ const hasUrl = Object.values(p.urls).some((u) => {
98
+ const host = extractHost(u);
99
+ return host === domainLower || host === "www." + domainLower;
100
+ });
101
+ if (hasUrl)
102
+ score += 3;
103
+ }
104
+ // Check tags — require tag to be at least 4 chars to avoid phantom matches (e.g. "x" matching "example.com")
105
+ if (p.tags.some((t) => t.length >= 4 && domainLower.includes(t.toLowerCase())))
106
+ score += 1;
107
+ // Weight by reliability — floor at 0.1 so valid matches are never zeroed out
108
+ if (score > 0 && p.successCount + p.failCount > 0) {
109
+ score *= Math.max(0.1, p.successCount / (p.successCount + p.failCount));
110
+ }
111
+ if (score > bestScore) {
112
+ bestScore = score;
113
+ best = p;
114
+ }
115
+ }
116
+ return bestScore > 0 ? best : null;
117
+ }
118
+ /** Find best playbook matching a macOS bundle ID (e.g., "com.blackmagic-design.DaVinciResolveLite"). */
119
+ matchByBundleId(bundleId) {
120
+ const idLower = bundleId.toLowerCase();
121
+ // Extract the short app name from bundleId (e.g., "davinciresolvelite" from "com.blackmagic-design.DaVinciResolveLite")
122
+ const shortName = idLower.split(".").pop() ?? idLower;
123
+ let best = null;
124
+ let bestScore = 0;
125
+ for (const p of this.playbooks.values()) {
126
+ let score = 0;
127
+ // Direct bundleId match (if the reference stores it)
128
+ if (p.bundleId?.toLowerCase() === idLower)
129
+ score += 10;
130
+ // Check platform name against short app name
131
+ const platLower = p.platform.toLowerCase().replace(/[^a-z0-9]/g, "");
132
+ const shortClean = shortName.replace(/[^a-z0-9]/g, "");
133
+ if (platLower.includes(shortClean) || shortClean.includes(platLower))
134
+ score += 3;
135
+ // Check tags — require tag to be at least 4 chars to avoid phantom matches (e.g. "com")
136
+ if (p.tags.some((t) => {
137
+ const cleaned = t.toLowerCase().replace(/[^a-z0-9.]/g, "");
138
+ return cleaned.length >= 4 && idLower.includes(cleaned);
139
+ }))
140
+ score += 1;
141
+ // Weight by reliability — floor at 0.1 so valid matches are never zeroed out
142
+ if (score > 0 && p.successCount + p.failCount > 0) {
143
+ score *= Math.max(0.1, p.successCount / (p.successCount + p.failCount));
144
+ }
145
+ if (score > bestScore) {
146
+ bestScore = score;
147
+ best = p;
148
+ }
149
+ }
150
+ return bestScore > 0 ? best : null;
151
+ }
60
152
  /** Find playbooks matching a URL. */
61
153
  matchByUrl(url) {
62
154
  return this.getAll().filter((p) => {
@@ -64,7 +156,15 @@ export class PlaybookStore {
64
156
  return false;
65
157
  return p.urlPatterns.some((pattern) => {
66
158
  try {
67
- return new RegExp(pattern).test(url);
159
+ // Guard against ReDoS: reject patterns that could cause catastrophic backtracking
160
+ if (/([+*])\1|(\([^)]*[+*][^)]*\))[+*]/.test(pattern)) {
161
+ return url.includes(pattern);
162
+ }
163
+ const re = new RegExp(pattern);
164
+ // Use a timeout-safe approach: test with a length limit
165
+ if (url.length > 2048)
166
+ return false;
167
+ return re.test(url);
68
168
  }
69
169
  catch {
70
170
  return url.includes(pattern);
@@ -86,38 +186,65 @@ export class PlaybookStore {
86
186
  .sort((a, b) => b.successCount - a.successCount);
87
187
  }
88
188
  /** Find best playbook for a task description (simple keyword matching). */
89
- matchByTask(task) {
90
- const tokens = task.toLowerCase().split(/\W+/).filter((w) => w.length >= 3);
189
+ matchByTask(task, currentBundleId) {
190
+ // Filter out very common words that cause false matches across unrelated playbooks
191
+ const STOP_WORDS = new Set([
192
+ "the", "and", "for", "with", "from", "into", "that", "this", "then",
193
+ "type", "click", "open", "close", "save", "new", "text", "file",
194
+ "app", "use", "set", "get", "add", "run", "start", "stop",
195
+ "page", "post", "button", "menu", "tab", "window", "link",
196
+ "send", "copy", "paste", "delete", "create", "edit", "view",
197
+ "show", "hide", "move", "drag", "drop", "enter", "press",
198
+ "input", "form", "image", "video", "upload", "download",
199
+ "navigate", "select", "find", "wait", "about",
200
+ ]);
201
+ const tokens = task.toLowerCase().split(/\W+/)
202
+ .filter((w) => w.length >= 3 && !STOP_WORDS.has(w));
91
203
  if (tokens.length === 0)
92
204
  return null;
93
205
  let best = null;
94
206
  let bestScore = 0;
207
+ let bestRawScore = 0;
95
208
  for (const p of this.playbooks.values()) {
96
209
  const haystack = `${p.name} ${p.description} ${p.tags.join(" ")} ${p.platform}`.toLowerCase();
97
- let score = 0;
210
+ let rawScore = 0;
98
211
  for (const token of tokens) {
99
212
  if (haystack.includes(token))
100
- score++;
213
+ rawScore++;
214
+ }
215
+ // Boost playbooks whose bundleId matches the current app
216
+ if (currentBundleId && p.bundleId && p.bundleId.toLowerCase() === currentBundleId.toLowerCase()) {
217
+ rawScore += 2;
101
218
  }
102
- // Weight by reliability
219
+ // Weight by reliability for ranking, but check threshold against raw score
103
220
  const reliability = p.successCount + p.failCount > 0
104
221
  ? p.successCount / (p.successCount + p.failCount)
105
222
  : 0.5;
106
- score *= reliability;
223
+ const score = rawScore * reliability;
107
224
  if (score > bestScore) {
108
225
  bestScore = score;
226
+ bestRawScore = rawScore;
109
227
  best = p;
110
228
  }
111
229
  }
112
- return bestScore > 0 ? best : null;
230
+ // Require at least 50% of meaningful tokens to match (raw, before reliability weighting)
231
+ // AND at least 2 raw token matches to prevent single-word false positives
232
+ const minRawScore = Math.max(Math.ceil(tokens.length * 0.5), 2);
233
+ return bestRawScore >= minRawScore ? best : null;
113
234
  }
114
235
  /** Save a playbook to disk. */
115
236
  save(playbook) {
116
237
  if (!fs.existsSync(this.dir)) {
117
238
  fs.mkdirSync(this.dir, { recursive: true });
118
239
  }
119
- const filename = `${playbook.id}.json`;
120
- writeFileAtomicSync(path.join(this.dir, filename), JSON.stringify(playbook, null, 2) + "\n");
240
+ // Sanitize ID to prevent path traversal, truncate to avoid ENAMETOOLONG (max 255 on macOS/Linux)
241
+ const safeId = playbook.id.replace(/[^a-zA-Z0-9_\-]/g, "_").slice(0, 200);
242
+ const filename = `${safeId}.json`;
243
+ const resolved = path.resolve(path.join(this.dir, filename));
244
+ if (!resolved.startsWith(path.resolve(this.dir))) {
245
+ return; // Path traversal attempt — refuse to write
246
+ }
247
+ writeFileAtomicSync(resolved, JSON.stringify(playbook, null, 2) + "\n");
121
248
  this.playbooks.set(playbook.id, playbook);
122
249
  }
123
250
  /** Record a run outcome. */
@@ -169,6 +296,8 @@ export class PlaybookStore {
169
296
  successCount: 0,
170
297
  failCount: 0,
171
298
  // Preserve rich metadata
299
+ ...(raw.bundleId ? { bundleId: raw.bundleId } : {}),
300
+ ...(raw.cdpPort ? { cdpPort: raw.cdpPort } : {}),
172
301
  ...(raw.urls ? { urls: raw.urls } : {}),
173
302
  ...(raw.selectors ? { selectors: raw.selectors } : {}),
174
303
  ...(raw.flows ? { flows: raw.flows } : {}),
@@ -0,0 +1,156 @@
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
+ const PERMISSION_PATTERNS = /access|allow|permission|accessibility|camera|microphone|location|contacts/i;
18
+ const LOGIN_PATTERNS = /sign in|log in|login|session expired|authenticate/i;
19
+ const CAPTCHA_PATTERNS = /captcha|verify you.re human|are you a robot/i;
20
+ const RATE_LIMIT_PATTERNS = /rate limit|too many requests|slow down/i;
21
+ const NETWORK_ERROR_PATTERNS = /network error|failed to load|no internet|connection refused/i;
22
+ const CRASH_DIALOG_PATTERNS = /not responding|crashed|quit unexpectedly/i;
23
+ const POPUP_BLOCKED_PATTERNS = /popup.*block|pop-up.*block|blocked.*popup|blocked.*pop-up|window\.open.*blocked/i;
24
+ /**
25
+ * Scan the WorldModel and error text to detect blockers, sorted by priority.
26
+ * Returns an empty array only if no blockers can be inferred.
27
+ */
28
+ export function detectBlockers(worldModel, failedStepError, expectedBundleId) {
29
+ const blockers = [];
30
+ const state = worldModel.getState();
31
+ const pidFields = state.focusedApp?.pid != null ? { pid: state.focusedApp.pid } : {};
32
+ // 1. Dialog-based blockers (highest priority)
33
+ const dialogs = worldModel.getActiveDialogs();
34
+ for (const dialog of dialogs) {
35
+ const type = classifyDialogType(dialog.title, dialog.type);
36
+ blockers.push({
37
+ type,
38
+ description: `${type}: "${dialog.title || dialog.type}"`,
39
+ bundleId: expectedBundleId,
40
+ ...pidFields,
41
+ dialogTitle: dialog.title,
42
+ });
43
+ }
44
+ // 2. Focus loss
45
+ if (expectedBundleId !== null) {
46
+ const focusedApp = state.focusedApp;
47
+ if (focusedApp && focusedApp.bundleId !== expectedBundleId) {
48
+ blockers.push({
49
+ type: "focus_lost",
50
+ description: `Expected ${expectedBundleId}, got ${focusedApp.bundleId}`,
51
+ bundleId: expectedBundleId,
52
+ ...pidFields,
53
+ });
54
+ }
55
+ if (!focusedApp && state.windows.size === 0) {
56
+ blockers.push({
57
+ type: "app_crashed",
58
+ description: `No windows tracked for ${expectedBundleId}`,
59
+ bundleId: expectedBundleId,
60
+ ...pidFields,
61
+ });
62
+ }
63
+ }
64
+ // 3. Many stale controls = world model outdated
65
+ const staleControls = worldModel.getStaleControls(10_000);
66
+ if (staleControls.length > 10) {
67
+ blockers.push({
68
+ type: "unknown_state",
69
+ description: `${staleControls.length} stale controls`,
70
+ bundleId: expectedBundleId,
71
+ ...pidFields,
72
+ });
73
+ }
74
+ // 4. Error text pattern matching
75
+ const err = failedStepError.toLowerCase();
76
+ if (CAPTCHA_PATTERNS.test(err)) {
77
+ blockers.push({ type: "captcha", description: `Captcha: ${failedStepError}`, bundleId: expectedBundleId, ...pidFields });
78
+ }
79
+ if (RATE_LIMIT_PATTERNS.test(err)) {
80
+ blockers.push({ type: "rate_limited", description: `Rate limited: ${failedStepError}`, bundleId: expectedBundleId, ...pidFields });
81
+ }
82
+ if (LOGIN_PATTERNS.test(err) && !blockers.some((b) => b.type === "login_required")) {
83
+ blockers.push({ type: "login_required", description: `Login required: ${failedStepError}`, bundleId: expectedBundleId, ...pidFields });
84
+ }
85
+ if (NETWORK_ERROR_PATTERNS.test(err)) {
86
+ blockers.push({ type: "network_error", description: `Network error: ${failedStepError}`, bundleId: expectedBundleId, ...pidFields });
87
+ }
88
+ if (POPUP_BLOCKED_PATTERNS.test(err)) {
89
+ blockers.push({ type: "permission_dialog", description: `Popup blocked: ${failedStepError}`, bundleId: expectedBundleId, ...pidFields });
90
+ }
91
+ if (/timed\s+out|timeout\s+(exceeded|error|after)|request\s+timeout/i.test(err) ||
92
+ /loading\s+(stuck|failed|error|taking)/i.test(err) ||
93
+ (err.includes("loading") && (err.includes("fail") || err.includes("error") || err.includes("stuck")))) {
94
+ blockers.push({ type: "loading_stuck", description: `Loading stuck: ${failedStepError}`, bundleId: expectedBundleId, ...pidFields });
95
+ }
96
+ if (err.includes("not found") || err.includes("locate_failed") || /element.*(gone|missing|disappear|not found)/i.test(err)) {
97
+ blockers.push({ type: "element_gone", description: `Element gone: ${failedStepError}`, bundleId: expectedBundleId, ...pidFields });
98
+ // 4a. selector_drift: element gone but UI controls were recently updated
99
+ // This means the element moved/changed rather than disappearing entirely
100
+ const focusedWindowId = state.focusedWindowId;
101
+ if (focusedWindowId !== null) {
102
+ const win = state.windows.get(focusedWindowId);
103
+ if (win && win.controls.size > 0) {
104
+ const FRESH_THRESHOLD = 5_000;
105
+ let hasFreshControls = false;
106
+ for (const ctrl of win.controls.values()) {
107
+ if (Date.now() - new Date(ctrl.value.updatedAt).getTime() < FRESH_THRESHOLD) {
108
+ hasFreshControls = true;
109
+ break;
110
+ }
111
+ }
112
+ if (hasFreshControls) {
113
+ blockers.push({
114
+ type: "selector_drift",
115
+ description: `Selector drift: element not found but UI is fresh (${win.controls.size} controls)`,
116
+ bundleId: expectedBundleId,
117
+ ...pidFields,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ }
123
+ // 5. Fallback
124
+ if (blockers.length === 0) {
125
+ blockers.push({
126
+ type: "unknown_state",
127
+ description: `Unclassified: ${failedStepError}`,
128
+ bundleId: expectedBundleId,
129
+ ...pidFields,
130
+ });
131
+ }
132
+ // Deduplicate by type
133
+ const seen = new Set();
134
+ return blockers.filter((b) => {
135
+ if (seen.has(b.type))
136
+ return false;
137
+ seen.add(b.type);
138
+ return true;
139
+ });
140
+ }
141
+ function classifyDialogType(title, dialogRole) {
142
+ const titleLower = title.toLowerCase();
143
+ if (/pop-up|popup|blocked/i.test(titleLower))
144
+ return "permission_dialog";
145
+ if (PERMISSION_PATTERNS.test(titleLower))
146
+ return "permission_dialog";
147
+ if (LOGIN_PATTERNS.test(titleLower))
148
+ return "login_required";
149
+ if (CAPTCHA_PATTERNS.test(titleLower))
150
+ return "captcha";
151
+ if (CRASH_DIALOG_PATTERNS.test(titleLower))
152
+ return "app_crashed";
153
+ if (dialogRole === "alert")
154
+ return "unexpected_dialog";
155
+ return "unexpected_dialog";
156
+ }