screenhand 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +193 -109
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +5876 -0
  4. package/dist/scripts/codex-monitor-daemon.js +335 -0
  5. package/dist/scripts/export-help-center.js +112 -0
  6. package/dist/scripts/marketing-loop.js +117 -0
  7. package/dist/scripts/observer-daemon.js +288 -0
  8. package/dist/scripts/orchestrator-daemon.js +399 -0
  9. package/dist/scripts/supervisor-daemon.js +272 -0
  10. package/dist/scripts/threads-campaign.js +208 -0
  11. package/dist/scripts/worker-daemon.js +228 -0
  12. package/dist/src/agent/cli.js +82 -0
  13. package/dist/src/agent/loop.js +274 -0
  14. package/dist/src/community/fetcher.js +109 -0
  15. package/dist/src/community/index.js +6 -0
  16. package/dist/src/community/publisher.js +191 -0
  17. package/dist/src/community/remote-api.js +121 -0
  18. package/dist/src/community/types.js +3 -0
  19. package/dist/src/community/validator.js +95 -0
  20. package/{src/config.ts → dist/src/config.js} +5 -10
  21. package/dist/src/context-tracker.js +489 -0
  22. package/{src/index.ts → dist/src/index.js} +32 -52
  23. package/dist/src/ingestion/coverage-auditor.js +233 -0
  24. package/dist/src/ingestion/doc-parser.js +164 -0
  25. package/dist/src/ingestion/index.js +8 -0
  26. package/dist/src/ingestion/menu-scanner.js +152 -0
  27. package/dist/src/ingestion/reference-merger.js +186 -0
  28. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  29. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  30. package/dist/src/ingestion/types.js +3 -0
  31. package/dist/src/jobs/manager.js +305 -0
  32. package/dist/src/jobs/runner.js +806 -0
  33. package/dist/src/jobs/store.js +102 -0
  34. package/dist/src/jobs/types.js +30 -0
  35. package/dist/src/jobs/worker.js +97 -0
  36. package/dist/src/learning/engine.js +356 -0
  37. package/dist/src/learning/index.js +9 -0
  38. package/dist/src/learning/locator-policy.js +120 -0
  39. package/dist/src/learning/pattern-policy.js +89 -0
  40. package/dist/src/learning/recovery-policy.js +116 -0
  41. package/dist/src/learning/sensor-policy.js +115 -0
  42. package/dist/src/learning/timing-model.js +204 -0
  43. package/dist/src/learning/topology-policy.js +90 -0
  44. package/dist/src/learning/types.js +9 -0
  45. package/dist/src/logging/timeline-logger.js +48 -0
  46. package/dist/src/mcp/mcp-stdio-server.js +464 -0
  47. package/dist/src/mcp/server.js +363 -0
  48. package/dist/src/mcp-entry.js +60 -0
  49. package/dist/src/memory/playbook-seeds.js +200 -0
  50. package/dist/src/memory/recall.js +222 -0
  51. package/dist/src/memory/research.js +104 -0
  52. package/dist/src/memory/seeds.js +101 -0
  53. package/dist/src/memory/service.js +446 -0
  54. package/dist/src/memory/session.js +169 -0
  55. package/dist/src/memory/store.js +451 -0
  56. package/{src/runtime/locator-cache.ts → dist/src/memory/types.js} +1 -17
  57. package/dist/src/monitor/codex-monitor.js +382 -0
  58. package/dist/src/monitor/task-queue.js +97 -0
  59. package/dist/src/monitor/types.js +62 -0
  60. package/dist/src/native/bridge-client.js +412 -0
  61. package/{src/native/macos-bridge-client.ts → dist/src/native/macos-bridge-client.js} +0 -1
  62. package/dist/src/observer/state.js +199 -0
  63. package/dist/src/observer/types.js +43 -0
  64. package/dist/src/orchestrator/state.js +68 -0
  65. package/dist/src/orchestrator/types.js +22 -0
  66. package/dist/src/perception/ax-source.js +162 -0
  67. package/dist/src/perception/cdp-source.js +162 -0
  68. package/dist/src/perception/coordinator.js +771 -0
  69. package/dist/src/perception/frame-differ.js +287 -0
  70. package/dist/src/perception/index.js +22 -0
  71. package/dist/src/perception/manager.js +199 -0
  72. package/dist/src/perception/types.js +47 -0
  73. package/dist/src/perception/vision-source.js +399 -0
  74. package/dist/src/planner/deterministic.js +298 -0
  75. package/dist/src/planner/executor.js +870 -0
  76. package/dist/src/planner/goal-store.js +92 -0
  77. package/dist/src/planner/index.js +21 -0
  78. package/dist/src/planner/planner.js +520 -0
  79. package/dist/src/planner/tool-registry.js +71 -0
  80. package/dist/src/planner/types.js +22 -0
  81. package/dist/src/platform/explorer.js +213 -0
  82. package/dist/src/platform/help-center-markdown.js +527 -0
  83. package/dist/src/platform/learner.js +257 -0
  84. package/dist/src/playbook/engine.js +486 -0
  85. package/dist/src/playbook/index.js +20 -0
  86. package/dist/src/playbook/mcp-recorder.js +204 -0
  87. package/dist/src/playbook/recorder.js +536 -0
  88. package/dist/src/playbook/runner.js +408 -0
  89. package/dist/src/playbook/store.js +312 -0
  90. package/dist/src/playbook/types.js +17 -0
  91. package/dist/src/recovery/detectors.js +156 -0
  92. package/dist/src/recovery/engine.js +327 -0
  93. package/dist/src/recovery/index.js +20 -0
  94. package/dist/src/recovery/strategies.js +274 -0
  95. package/dist/src/recovery/types.js +20 -0
  96. package/dist/src/runtime/accessibility-adapter.js +430 -0
  97. package/dist/src/runtime/app-adapter.js +64 -0
  98. package/dist/src/runtime/applescript-adapter.js +305 -0
  99. package/dist/src/runtime/ax-role-map.js +96 -0
  100. package/dist/src/runtime/browser-adapter.js +52 -0
  101. package/dist/src/runtime/cdp-chrome-adapter.js +521 -0
  102. package/dist/src/runtime/composite-adapter.js +221 -0
  103. package/dist/src/runtime/execution-contract.js +159 -0
  104. package/dist/src/runtime/executor.js +286 -0
  105. package/dist/src/runtime/locator-cache.js +50 -0
  106. package/dist/src/runtime/planning-loop.js +63 -0
  107. package/dist/src/runtime/service.js +432 -0
  108. package/dist/src/runtime/session-manager.js +63 -0
  109. package/dist/src/runtime/state-observer.js +121 -0
  110. package/dist/src/runtime/vision-adapter.js +225 -0
  111. package/dist/src/state/app-map-types.js +72 -0
  112. package/dist/src/state/app-map.js +1974 -0
  113. package/dist/src/state/entity-tracker.js +108 -0
  114. package/dist/src/state/fusion.js +96 -0
  115. package/dist/src/state/index.js +21 -0
  116. package/dist/src/state/ladder-generator.js +236 -0
  117. package/dist/src/state/persistence.js +156 -0
  118. package/dist/src/state/types.js +17 -0
  119. package/dist/src/state/world-model.js +1456 -0
  120. package/dist/src/supervisor/locks.js +186 -0
  121. package/dist/src/supervisor/supervisor.js +403 -0
  122. package/dist/src/supervisor/types.js +30 -0
  123. package/dist/src/test-mcp-protocol.js +154 -0
  124. package/dist/src/types.js +17 -0
  125. package/dist/src/util/atomic-write.js +133 -0
  126. package/dist/src/util/sanitize.js +146 -0
  127. package/dist-app-maps/com.figma.Desktop.json +959 -0
  128. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  129. package/dist-app-maps/notion.id.json +2831 -0
  130. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  131. package/dist-playbooks/codex-desktop.json +76 -0
  132. package/dist-playbooks/competitor-research-stack.json +122 -0
  133. package/dist-playbooks/davinci-color-grade.json +153 -0
  134. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  135. package/dist-playbooks/davinci-render.json +114 -0
  136. package/dist-playbooks/devto.json +52 -0
  137. package/dist-playbooks/discord.json +41 -0
  138. package/dist-playbooks/google-flow-create-project.json +59 -0
  139. package/dist-playbooks/google-flow-edit-image.json +90 -0
  140. package/dist-playbooks/google-flow-edit-video.json +90 -0
  141. package/dist-playbooks/google-flow-generate-image.json +68 -0
  142. package/dist-playbooks/google-flow-generate-video.json +191 -0
  143. package/dist-playbooks/google-flow-open-project.json +48 -0
  144. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  145. package/dist-playbooks/google-flow-search-assets.json +64 -0
  146. package/dist-playbooks/instagram.json +57 -0
  147. package/dist-playbooks/linkedin.json +52 -0
  148. package/dist-playbooks/n8n.json +43 -0
  149. package/dist-playbooks/reddit.json +52 -0
  150. package/dist-playbooks/threads.json +59 -0
  151. package/dist-playbooks/x-twitter.json +59 -0
  152. package/dist-playbooks/youtube.json +59 -0
  153. package/dist-references/canva.json +646 -0
  154. package/dist-references/codex-desktop.json +305 -0
  155. package/dist-references/davinci-resolve-keyboard.json +594 -0
  156. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  157. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  158. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  159. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  160. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  161. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  162. package/dist-references/devto.json +317 -0
  163. package/dist-references/discord.json +549 -0
  164. package/dist-references/figma.json +1186 -0
  165. package/dist-references/finder.json +146 -0
  166. package/dist-references/google-ads-transparency.json +95 -0
  167. package/dist-references/google-flow.json +649 -0
  168. package/dist-references/instagram.json +341 -0
  169. package/dist-references/linkedin.json +324 -0
  170. package/dist-references/meta-ad-library.json +86 -0
  171. package/dist-references/n8n.json +387 -0
  172. package/dist-references/notes.json +27 -0
  173. package/dist-references/notion.json +163 -0
  174. package/dist-references/reddit.json +341 -0
  175. package/dist-references/threads.json +337 -0
  176. package/dist-references/x-twitter.json +403 -0
  177. package/dist-references/youtube.json +373 -0
  178. package/native/macos-bridge/Package.swift +1 -0
  179. package/native/macos-bridge/Sources/AccessibilityBridge.swift +257 -36
  180. package/native/macos-bridge/Sources/AppManagement.swift +212 -2
  181. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +348 -53
  182. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  183. package/native/macos-bridge/Sources/VisionBridge.swift +165 -7
  184. package/native/macos-bridge/Sources/main.swift +169 -16
  185. package/native/windows-bridge/Program.cs +5 -0
  186. package/native/windows-bridge/ScreenCapture.cs +124 -0
  187. package/package.json +29 -4
  188. package/scripts/postinstall.cjs +127 -0
  189. package/.claude/commands/automate.md +0 -28
  190. package/.claude/commands/debug-ui.md +0 -19
  191. package/.claude/commands/screenshot.md +0 -15
  192. package/.github/FUNDING.yml +0 -1
  193. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -27
  194. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  195. package/.mcp.json +0 -8
  196. package/DESKTOP_MCP_GUIDE.md +0 -92
  197. package/SECURITY.md +0 -44
  198. package/docs/architecture.md +0 -47
  199. package/install-skills.sh +0 -19
  200. package/mcp-bridge.ts +0 -271
  201. package/mcp-desktop.ts +0 -1221
  202. package/playbooks/instagram.json +0 -41
  203. package/playbooks/instagram_v2.json +0 -201
  204. package/playbooks/x_v1.json +0 -211
  205. package/scripts/devpost-live-loop.mjs +0 -421
  206. package/src/logging/timeline-logger.ts +0 -55
  207. package/src/mcp/server.ts +0 -449
  208. package/src/memory/recall.ts +0 -191
  209. package/src/memory/research.ts +0 -146
  210. package/src/memory/seeds.ts +0 -123
  211. package/src/memory/session.ts +0 -201
  212. package/src/memory/store.ts +0 -434
  213. package/src/memory/types.ts +0 -69
  214. package/src/native/bridge-client.ts +0 -239
  215. package/src/runtime/accessibility-adapter.ts +0 -487
  216. package/src/runtime/app-adapter.ts +0 -169
  217. package/src/runtime/applescript-adapter.ts +0 -376
  218. package/src/runtime/ax-role-map.ts +0 -102
  219. package/src/runtime/browser-adapter.ts +0 -129
  220. package/src/runtime/cdp-chrome-adapter.ts +0 -676
  221. package/src/runtime/composite-adapter.ts +0 -274
  222. package/src/runtime/executor.ts +0 -396
  223. package/src/runtime/planning-loop.ts +0 -81
  224. package/src/runtime/service.ts +0 -448
  225. package/src/runtime/session-manager.ts +0 -50
  226. package/src/runtime/state-observer.ts +0 -136
  227. package/src/runtime/vision-adapter.ts +0 -297
  228. package/src/types.ts +0 -297
  229. package/tests/bridge-client.test.ts +0 -176
  230. package/tests/browser-stealth.test.ts +0 -210
  231. package/tests/composite-adapter.test.ts +0 -64
  232. package/tests/mcp-server.test.ts +0 -151
  233. package/tests/memory-recall.test.ts +0 -339
  234. package/tests/memory-research.test.ts +0 -159
  235. package/tests/memory-seeds.test.ts +0 -120
  236. package/tests/memory-store.test.ts +0 -392
  237. package/tests/types.test.ts +0 -92
  238. package/tsconfig.check.json +0 -17
  239. package/tsconfig.json +0 -19
  240. package/vitest.config.ts +0 -8
  241. /package/{playbooks → dist-references}/devpost.json +0 -0
@@ -0,0 +1,312 @@
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
+ * Playbook Store — load, save, match playbooks from disk
19
+ *
20
+ * Playbooks are stored as JSON files in the playbooks/ directory.
21
+ * Each file can contain a Playbook directly, or a legacy format
22
+ * that gets converted.
23
+ */
24
+ import fs from "node:fs";
25
+ import path from "node:path";
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
+ }
41
+ export class PlaybookStore {
42
+ dir;
43
+ playbooks = new Map();
44
+ constructor(dir) {
45
+ this.dir = dir;
46
+ }
47
+ /** Load all playbooks from disk into memory. */
48
+ load() {
49
+ this.playbooks.clear();
50
+ if (!fs.existsSync(this.dir))
51
+ return;
52
+ const files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".json"));
53
+ for (const file of files) {
54
+ try {
55
+ const raw = JSON.parse(fs.readFileSync(path.join(this.dir, file), "utf-8"));
56
+ const playbook = this.normalize(raw, file);
57
+ if (playbook) {
58
+ this.playbooks.set(playbook.id, playbook);
59
+ }
60
+ }
61
+ catch {
62
+ // Skip unparseable files
63
+ }
64
+ }
65
+ }
66
+ /** Get all loaded playbooks. */
67
+ getAll() {
68
+ return [...this.playbooks.values()];
69
+ }
70
+ /** Get a playbook by ID. */
71
+ get(id) {
72
+ return this.playbooks.get(id);
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
+ }
152
+ /** Find playbooks matching a URL. */
153
+ matchByUrl(url) {
154
+ return this.getAll().filter((p) => {
155
+ if (!p.urlPatterns || p.urlPatterns.length === 0)
156
+ return false;
157
+ return p.urlPatterns.some((pattern) => {
158
+ try {
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);
168
+ }
169
+ catch {
170
+ return url.includes(pattern);
171
+ }
172
+ });
173
+ });
174
+ }
175
+ /** Find playbooks matching tags. */
176
+ matchByTags(tags) {
177
+ const tagSet = new Set(tags.map((t) => t.toLowerCase()));
178
+ return this.getAll()
179
+ .filter((p) => p.tags.some((t) => tagSet.has(t.toLowerCase())))
180
+ .sort((a, b) => b.successCount - a.successCount);
181
+ }
182
+ /** Find playbooks by platform. */
183
+ matchByPlatform(platform) {
184
+ return this.getAll()
185
+ .filter((p) => p.platform.toLowerCase() === platform.toLowerCase())
186
+ .sort((a, b) => b.successCount - a.successCount);
187
+ }
188
+ /** Find best playbook for a task description (simple keyword matching). */
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));
203
+ if (tokens.length === 0)
204
+ return null;
205
+ let best = null;
206
+ let bestScore = 0;
207
+ let bestRawScore = 0;
208
+ for (const p of this.playbooks.values()) {
209
+ const haystack = `${p.name} ${p.description} ${p.tags.join(" ")} ${p.platform}`.toLowerCase();
210
+ let rawScore = 0;
211
+ for (const token of tokens) {
212
+ if (haystack.includes(token))
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;
218
+ }
219
+ // Weight by reliability for ranking, but check threshold against raw score
220
+ const reliability = p.successCount + p.failCount > 0
221
+ ? p.successCount / (p.successCount + p.failCount)
222
+ : 0.5;
223
+ const score = rawScore * reliability;
224
+ if (score > bestScore) {
225
+ bestScore = score;
226
+ bestRawScore = rawScore;
227
+ best = p;
228
+ }
229
+ }
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;
234
+ }
235
+ /** Save a playbook to disk. */
236
+ save(playbook) {
237
+ if (!fs.existsSync(this.dir)) {
238
+ fs.mkdirSync(this.dir, { recursive: true });
239
+ }
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");
248
+ this.playbooks.set(playbook.id, playbook);
249
+ }
250
+ /** Record a run outcome. */
251
+ recordOutcome(id, success) {
252
+ const playbook = this.playbooks.get(id);
253
+ if (!playbook)
254
+ return;
255
+ if (success) {
256
+ playbook.successCount++;
257
+ }
258
+ else {
259
+ playbook.failCount++;
260
+ }
261
+ playbook.lastRun = new Date().toISOString();
262
+ this.save(playbook);
263
+ }
264
+ /**
265
+ * Normalize raw JSON into a Playbook.
266
+ * Handles both new format (has steps array) and legacy format (has flows object).
267
+ */
268
+ normalize(raw, filename) {
269
+ // Already in new format
270
+ if (Array.isArray(raw.steps)) {
271
+ return raw;
272
+ }
273
+ // Legacy format: has flows with steps arrays (like instagram_v2.json)
274
+ if (raw.flows && typeof raw.flows === "object") {
275
+ return this.convertLegacy(raw, filename);
276
+ }
277
+ return null;
278
+ }
279
+ /**
280
+ * Convert legacy playbook format to new format.
281
+ * Preserves all rich metadata: selectors, flows, errors, detection, policy notes.
282
+ */
283
+ convertLegacy(raw, filename) {
284
+ const platform = raw.platform ?? filename.replace(".json", "");
285
+ const id = raw.playbook ?? filename.replace(".json", "");
286
+ const description = raw.description ?? "";
287
+ return {
288
+ id,
289
+ name: `${platform} playbook`,
290
+ description,
291
+ platform,
292
+ urlPatterns: raw.urls ? Object.values(raw.urls).map(escapeRegex) : [],
293
+ steps: [], // Legacy playbooks use flows instead of direct steps
294
+ tags: [platform, ...(raw.playbook ? [raw.playbook] : [])],
295
+ version: raw.version ?? "1.0.0",
296
+ successCount: 0,
297
+ failCount: 0,
298
+ // Preserve rich metadata
299
+ ...(raw.bundleId ? { bundleId: raw.bundleId } : {}),
300
+ ...(raw.cdpPort ? { cdpPort: raw.cdpPort } : {}),
301
+ ...(raw.urls ? { urls: raw.urls } : {}),
302
+ ...(raw.selectors ? { selectors: raw.selectors } : {}),
303
+ ...(raw.flows ? { flows: raw.flows } : {}),
304
+ ...(raw.detection ? { detection: raw.detection } : {}),
305
+ ...(raw.errors ? { errors: raw.errors } : {}),
306
+ ...(raw.policy_notes ? { policyNotes: raw.policy_notes } : {}),
307
+ };
308
+ }
309
+ }
310
+ function escapeRegex(s) {
311
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
312
+ }
@@ -0,0 +1,17 @@
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 {};
@@ -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
+ }