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.
- package/README.md +165 -446
- package/bin/darwin-arm64/macos-bridge +0 -0
- package/dist/mcp-desktop.js +3615 -400
- package/dist/scripts/export-help-center.js +112 -0
- package/dist/scripts/marketing-loop.js +117 -0
- package/dist/scripts/observer-daemon.js +288 -0
- package/dist/scripts/orchestrator-daemon.js +399 -0
- package/dist/scripts/threads-campaign.js +208 -0
- package/dist/src/community/fetcher.js +109 -0
- package/dist/src/community/index.js +6 -0
- package/dist/src/community/publisher.js +191 -0
- package/dist/src/community/remote-api.js +121 -0
- package/dist/src/community/types.js +3 -0
- package/dist/src/community/validator.js +95 -0
- package/dist/src/context-tracker.js +489 -0
- package/dist/src/ingestion/coverage-auditor.js +233 -0
- package/dist/src/ingestion/doc-parser.js +164 -0
- package/dist/src/ingestion/index.js +8 -0
- package/dist/src/ingestion/menu-scanner.js +152 -0
- package/dist/src/ingestion/reference-merger.js +186 -0
- package/dist/src/ingestion/shortcut-extractor.js +180 -0
- package/dist/src/ingestion/tutorial-extractor.js +170 -0
- package/dist/src/ingestion/types.js +3 -0
- package/dist/src/jobs/manager.js +82 -14
- package/dist/src/jobs/runner.js +138 -15
- package/dist/src/learning/engine.js +356 -0
- package/dist/src/learning/index.js +9 -0
- package/dist/src/learning/locator-policy.js +120 -0
- package/dist/src/learning/pattern-policy.js +89 -0
- package/dist/src/learning/recovery-policy.js +116 -0
- package/dist/src/learning/sensor-policy.js +115 -0
- package/dist/src/learning/timing-model.js +204 -0
- package/dist/src/learning/topology-policy.js +90 -0
- package/dist/src/learning/types.js +9 -0
- package/dist/src/logging/timeline-logger.js +4 -1
- package/dist/src/memory/playbook-seeds.js +200 -0
- package/dist/src/memory/recall.js +60 -8
- package/dist/src/memory/service.js +30 -5
- package/dist/src/memory/store.js +34 -5
- package/dist/src/native/bridge-client.js +253 -31
- package/dist/src/observer/state.js +199 -0
- package/dist/src/observer/types.js +43 -0
- package/dist/src/orchestrator/state.js +68 -0
- package/dist/src/orchestrator/types.js +22 -0
- package/dist/src/perception/ax-source.js +162 -0
- package/dist/src/perception/cdp-source.js +162 -0
- package/dist/src/perception/coordinator.js +771 -0
- package/dist/src/perception/frame-differ.js +287 -0
- package/dist/src/perception/index.js +22 -0
- package/dist/src/perception/manager.js +199 -0
- package/dist/src/perception/types.js +47 -0
- package/dist/src/perception/vision-source.js +399 -0
- package/dist/src/planner/deterministic.js +298 -0
- package/dist/src/planner/executor.js +870 -0
- package/dist/src/planner/goal-store.js +92 -0
- package/dist/src/planner/index.js +21 -0
- package/dist/src/planner/planner.js +520 -0
- package/dist/src/planner/tool-registry.js +71 -0
- package/dist/src/planner/types.js +22 -0
- package/dist/src/platform/explorer.js +213 -0
- package/dist/src/platform/help-center-markdown.js +527 -0
- package/dist/src/platform/learner.js +257 -0
- package/dist/src/playbook/engine.js +296 -11
- package/dist/src/playbook/mcp-recorder.js +204 -0
- package/dist/src/playbook/recorder.js +3 -2
- package/dist/src/playbook/runner.js +1 -1
- package/dist/src/playbook/store.js +139 -10
- package/dist/src/recovery/detectors.js +156 -0
- package/dist/src/recovery/engine.js +327 -0
- package/dist/src/recovery/index.js +20 -0
- package/dist/src/recovery/strategies.js +274 -0
- package/dist/src/recovery/types.js +20 -0
- package/dist/src/runtime/accessibility-adapter.js +55 -18
- package/dist/src/runtime/applescript-adapter.js +8 -2
- package/dist/src/runtime/cdp-chrome-adapter.js +1 -1
- package/dist/src/runtime/executor.js +23 -3
- package/dist/src/runtime/locator-cache.js +24 -2
- package/dist/src/runtime/service.js +59 -15
- package/dist/src/runtime/session-manager.js +4 -1
- package/dist/src/runtime/vision-adapter.js +2 -1
- package/dist/src/state/app-map-types.js +72 -0
- package/dist/src/state/app-map.js +1974 -0
- package/dist/src/state/entity-tracker.js +108 -0
- package/dist/src/state/fusion.js +96 -0
- package/dist/src/state/index.js +21 -0
- package/dist/src/state/ladder-generator.js +236 -0
- package/dist/src/state/persistence.js +156 -0
- package/dist/src/state/types.js +17 -0
- package/dist/src/state/world-model.js +1456 -0
- package/dist/src/util/atomic-write.js +19 -4
- package/dist/src/util/sanitize.js +146 -0
- package/dist-app-maps/com.figma.Desktop.json +959 -0
- package/dist-app-maps/com.hnc.Discord.json +1146 -0
- package/dist-app-maps/notion.id.json +2831 -0
- package/dist-playbooks/canva-screenhand-carousel.json +445 -0
- package/dist-playbooks/codex-desktop.json +76 -0
- package/dist-playbooks/competitor-research-stack.json +122 -0
- package/dist-playbooks/davinci-color-grade.json +153 -0
- package/dist-playbooks/davinci-edit-timeline.json +162 -0
- package/dist-playbooks/davinci-render.json +114 -0
- package/dist-playbooks/devto.json +52 -0
- package/dist-playbooks/discord.json +41 -0
- package/dist-playbooks/google-flow-create-project.json +59 -0
- package/dist-playbooks/google-flow-edit-image.json +90 -0
- package/dist-playbooks/google-flow-edit-video.json +90 -0
- package/dist-playbooks/google-flow-generate-image.json +68 -0
- package/dist-playbooks/google-flow-generate-video.json +191 -0
- package/dist-playbooks/google-flow-open-project.json +48 -0
- package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
- package/dist-playbooks/google-flow-search-assets.json +64 -0
- package/dist-playbooks/instagram.json +57 -0
- package/dist-playbooks/linkedin.json +52 -0
- package/dist-playbooks/n8n.json +43 -0
- package/dist-playbooks/reddit.json +52 -0
- package/dist-playbooks/threads.json +59 -0
- package/dist-playbooks/x-twitter.json +59 -0
- package/dist-playbooks/youtube.json +59 -0
- package/dist-references/canva.json +646 -0
- package/dist-references/codex-desktop.json +305 -0
- package/dist-references/davinci-resolve-keyboard.json +594 -0
- package/dist-references/davinci-resolve-menu-map.json +1139 -0
- package/dist-references/davinci-resolve-menus-batch1.json +116 -0
- package/dist-references/davinci-resolve-menus-batch2.json +372 -0
- package/dist-references/davinci-resolve-menus-batch3.json +330 -0
- package/dist-references/davinci-resolve-menus-batch4.json +297 -0
- package/dist-references/davinci-resolve-shortcuts.json +333 -0
- package/dist-references/devpost.json +186 -0
- package/dist-references/devto.json +317 -0
- package/dist-references/discord.json +549 -0
- package/dist-references/figma.json +1186 -0
- package/dist-references/finder.json +146 -0
- package/dist-references/google-ads-transparency.json +95 -0
- package/dist-references/google-flow.json +649 -0
- package/dist-references/instagram.json +341 -0
- package/dist-references/linkedin.json +324 -0
- package/dist-references/meta-ad-library.json +86 -0
- package/dist-references/n8n.json +387 -0
- package/dist-references/notes.json +27 -0
- package/dist-references/notion.json +163 -0
- package/dist-references/reddit.json +341 -0
- package/dist-references/threads.json +337 -0
- package/dist-references/x-twitter.json +403 -0
- package/dist-references/youtube.json +373 -0
- package/native/macos-bridge/Package.swift +22 -0
- package/native/macos-bridge/Sources/AccessibilityBridge.swift +482 -0
- package/native/macos-bridge/Sources/AppManagement.swift +339 -0
- package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +537 -0
- package/native/macos-bridge/Sources/ObserverBridge.swift +120 -0
- package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
- package/native/macos-bridge/Sources/VisionBridge.swift +238 -0
- package/native/macos-bridge/Sources/main.swift +498 -0
- package/native/windows-bridge/AppManagement.cs +234 -0
- package/native/windows-bridge/InputBridge.cs +436 -0
- package/native/windows-bridge/Program.cs +270 -0
- package/native/windows-bridge/ScreenCapture.cs +453 -0
- package/native/windows-bridge/UIAutomationBridge.cs +571 -0
- package/native/windows-bridge/WindowsBridge.csproj +17 -0
- package/package.json +12 -1
- package/scripts/postinstall.cjs +127 -0
- package/dist/.audit-log.jsonl +0 -55
- package/dist/.screenhand/memory/.lock +0 -1
- package/dist/.screenhand/memory/actions.jsonl +0 -85
- package/dist/.screenhand/memory/errors.jsonl +0 -5
- package/dist/.screenhand/memory/errors.jsonl.bak +0 -4
- package/dist/.screenhand/memory/state.json +0 -35
- package/dist/.screenhand/memory/state.json.bak +0 -35
- package/dist/.screenhand/memory/strategies.jsonl +0 -12
- package/dist/agent/cli.js +0 -73
- package/dist/agent/loop.js +0 -258
- package/dist/config.js +0 -9
- package/dist/index.js +0 -56
- package/dist/logging/timeline-logger.js +0 -29
- package/dist/mcp/mcp-stdio-server.js +0 -448
- package/dist/mcp/server.js +0 -347
- package/dist/mcp-entry.js +0 -59
- package/dist/memory/recall.js +0 -160
- package/dist/memory/research.js +0 -98
- package/dist/memory/seeds.js +0 -89
- package/dist/memory/session.js +0 -161
- package/dist/memory/store.js +0 -391
- package/dist/memory/types.js +0 -4
- package/dist/monitor/codex-monitor.js +0 -377
- package/dist/monitor/task-queue.js +0 -84
- package/dist/monitor/types.js +0 -49
- package/dist/native/bridge-client.js +0 -174
- package/dist/native/macos-bridge-client.js +0 -5
- package/dist/npm-publish-helper.js +0 -117
- package/dist/npm-token-cdp.js +0 -113
- package/dist/npm-token-create.js +0 -135
- package/dist/npm-token-finish.js +0 -126
- package/dist/playbook/engine.js +0 -193
- package/dist/playbook/index.js +0 -4
- package/dist/playbook/recorder.js +0 -519
- package/dist/playbook/runner.js +0 -392
- package/dist/playbook/store.js +0 -166
- package/dist/playbook/types.js +0 -4
- package/dist/runtime/accessibility-adapter.js +0 -377
- package/dist/runtime/app-adapter.js +0 -48
- package/dist/runtime/applescript-adapter.js +0 -283
- package/dist/runtime/ax-role-map.js +0 -80
- package/dist/runtime/browser-adapter.js +0 -36
- package/dist/runtime/cdp-chrome-adapter.js +0 -505
- package/dist/runtime/composite-adapter.js +0 -205
- package/dist/runtime/executor.js +0 -250
- package/dist/runtime/locator-cache.js +0 -12
- package/dist/runtime/planning-loop.js +0 -47
- package/dist/runtime/service.js +0 -372
- package/dist/runtime/session-manager.js +0 -28
- package/dist/runtime/state-observer.js +0 -105
- package/dist/runtime/vision-adapter.js +0 -208
- package/dist/test-mcp-protocol.js +0 -138
- 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
|
-
|
|
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
|
-
|
|
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
|
|
210
|
+
let rawScore = 0;
|
|
98
211
|
for (const token of tokens) {
|
|
99
212
|
if (haystack.includes(token))
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
+
}
|