time to rethink architecture of this...
This commit is contained in:
+160
-5
@@ -2,9 +2,10 @@
|
||||
* Action execution module for DroidClaw.
|
||||
* Handles all ADB commands for interacting with Android devices.
|
||||
*
|
||||
* Supported actions:
|
||||
* Supported actions (21):
|
||||
* tap, type, enter, swipe, home, back, wait, done,
|
||||
* longpress, screenshot, launch, clear, clipboard_get, clipboard_set, shell
|
||||
* longpress, screenshot, launch, clear, clipboard_get, clipboard_set, paste, shell,
|
||||
* submit_message, copy_visible_text, wait_for_content, find_and_tap, compose_email
|
||||
*/
|
||||
|
||||
import { Config } from "./config.js";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
KEYCODE_DEL,
|
||||
KEYCODE_MOVE_HOME,
|
||||
KEYCODE_MOVE_END,
|
||||
KEYCODE_PASTE,
|
||||
SWIPE_COORDS,
|
||||
SWIPE_DURATION_MS,
|
||||
LONG_PRESS_DURATION_MS,
|
||||
@@ -42,6 +44,9 @@ export interface ActionDecision {
|
||||
think?: string;
|
||||
plan?: string[];
|
||||
planProgress?: string;
|
||||
// multi-step action fields (Phase 6)
|
||||
skill?: string; // legacy: kept for backward compat, prefer action field directly
|
||||
query?: string; // email address for compose_email, search term for find_and_tap/copy_visible_text
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
@@ -138,7 +143,7 @@ export function initDeviceContext(resolution: [number, number]): void {
|
||||
}
|
||||
|
||||
/** Returns dynamic swipe coords if set, otherwise falls back to hardcoded defaults. */
|
||||
function getSwipeCoords(): Record<string, [number, number, number, number]> {
|
||||
export function getSwipeCoords(): Record<string, [number, number, number, number]> {
|
||||
return dynamicSwipeCoords ?? SWIPE_COORDS;
|
||||
}
|
||||
|
||||
@@ -175,20 +180,108 @@ export function executeAction(action: ActionDecision): ActionResult {
|
||||
return executeClipboardGet();
|
||||
case "clipboard_set":
|
||||
return executeClipboardSet(action);
|
||||
case "paste":
|
||||
return executePaste(action);
|
||||
case "shell":
|
||||
return executeShell(action);
|
||||
case "scroll":
|
||||
return executeScroll(action);
|
||||
default:
|
||||
console.log(`Warning: Unknown action: ${action.action}`);
|
||||
return { success: false, message: `Unknown action: ${action.action}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Coordinate Validation & Sanitization
|
||||
// ===========================================
|
||||
|
||||
/**
|
||||
* Sanitizes coordinates from the LLM response.
|
||||
* Handles: normal arrays, string values, single-element arrays with concatenated digits.
|
||||
* Returns a proper [x, y] tuple or undefined if unrecoverable.
|
||||
*/
|
||||
export function sanitizeCoordinates(
|
||||
raw: unknown
|
||||
): [number, number] | undefined {
|
||||
if (raw == null) return undefined;
|
||||
|
||||
// Normal case: [x, y] array with two numbers
|
||||
if (Array.isArray(raw) && raw.length >= 2) {
|
||||
const x = Number(raw[0]);
|
||||
const y = Number(raw[1]);
|
||||
if (Number.isFinite(x) && Number.isFinite(y) && x <= 10000 && y <= 10000) {
|
||||
return [Math.round(x), Math.round(y)];
|
||||
}
|
||||
}
|
||||
|
||||
// Single-element array with concatenated number, e.g. [8282017] → [828, 2017]
|
||||
if (Array.isArray(raw) && raw.length === 1) {
|
||||
const split = trySplitConcatenated(Number(raw[0]));
|
||||
if (split) return split;
|
||||
}
|
||||
|
||||
// Plain concatenated number (not in array)
|
||||
if (typeof raw === "number" && raw > 10000) {
|
||||
const split = trySplitConcatenated(raw);
|
||||
if (split) return split;
|
||||
}
|
||||
|
||||
// String like "828, 2017" or "828 2017"
|
||||
if (typeof raw === "string") {
|
||||
const parts = (raw as string).split(/[,\s]+/).map(Number);
|
||||
if (parts.length >= 2 && parts.every(Number.isFinite)) {
|
||||
return [Math.round(parts[0]), Math.round(parts[1])];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to split a concatenated number like 8282017 into [828, 2017].
|
||||
* Attempts splits at positions 2, 3, and 4 digits for the x value.
|
||||
*/
|
||||
function trySplitConcatenated(n: number): [number, number] | null {
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const s = String(Math.round(n));
|
||||
// Try splitting at different positions (x is typically 2-4 digits)
|
||||
for (let i = 2; i <= Math.min(4, s.length - 2); i++) {
|
||||
const x = parseInt(s.slice(0, i), 10);
|
||||
const y = parseInt(s.slice(i), 10);
|
||||
if (x > 0 && x <= 3000 && y > 0 && y <= 5000) {
|
||||
return [x, y];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates coordinates are reasonable before executing ADB.
|
||||
* Returns [x, y] if valid, or null.
|
||||
*/
|
||||
function validateCoordinates(
|
||||
coords: [number, number] | undefined
|
||||
): [number, number] | null {
|
||||
if (!coords || !Array.isArray(coords) || coords.length < 2) return null;
|
||||
const [x, y] = coords;
|
||||
if (typeof x !== "number" || typeof y !== "number") return null;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
if (x < 0 || y < 0 || x > 10000 || y > 10000) return null;
|
||||
return [Math.round(x), Math.round(y)];
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Original actions (enhanced)
|
||||
// ===========================================
|
||||
|
||||
function executeTap(action: ActionDecision): ActionResult {
|
||||
const [x, y] = action.coordinates ?? [0, 0];
|
||||
const coords = validateCoordinates(action.coordinates);
|
||||
if (!coords) {
|
||||
console.log(`Invalid tap coordinates: ${JSON.stringify(action.coordinates)}`);
|
||||
return { success: false, message: `Invalid coordinates: ${JSON.stringify(action.coordinates)}` };
|
||||
}
|
||||
const [x, y] = coords;
|
||||
console.log(`Tapping: (${x}, ${y})`);
|
||||
runAdbCommand(["shell", "input", "tap", String(x), String(y)]);
|
||||
return { success: true, message: `Tapped (${x}, ${y})` };
|
||||
@@ -197,6 +290,17 @@ function executeTap(action: ActionDecision): ActionResult {
|
||||
function executeType(action: ActionDecision): ActionResult {
|
||||
const text = action.text ?? "";
|
||||
if (!text) return { success: false, message: "No text to type" };
|
||||
|
||||
// If coordinates are provided, tap the field first to focus it
|
||||
if (action.coordinates) {
|
||||
const coords = validateCoordinates(action.coordinates);
|
||||
if (coords) {
|
||||
console.log(`Focusing field: (${coords[0]}, ${coords[1]})`);
|
||||
runAdbCommand(["shell", "input", "tap", String(coords[0]), String(coords[1])]);
|
||||
Bun.sleepSync(300); // Brief pause for focus to register
|
||||
}
|
||||
}
|
||||
|
||||
// ADB requires %s for spaces, escape special shell characters
|
||||
const escapedText = text
|
||||
.replaceAll("\\", "\\\\")
|
||||
@@ -267,7 +371,12 @@ function executeDone(action: ActionDecision): ActionResult {
|
||||
* Long press at coordinates (opens context menus, triggers drag mode, etc.)
|
||||
*/
|
||||
function executeLongPress(action: ActionDecision): ActionResult {
|
||||
const [x, y] = action.coordinates ?? [0, 0];
|
||||
const coords = validateCoordinates(action.coordinates);
|
||||
if (!coords) {
|
||||
console.log(`Invalid longpress coordinates: ${JSON.stringify(action.coordinates)}`);
|
||||
return { success: false, message: `Invalid coordinates: ${JSON.stringify(action.coordinates)}` };
|
||||
}
|
||||
const [x, y] = coords;
|
||||
console.log(`Long pressing: (${x}, ${y})`);
|
||||
// A swipe from the same point to the same point with long duration = long press
|
||||
runAdbCommand([
|
||||
@@ -378,6 +487,52 @@ function executeClipboardSet(action: ActionDecision): ActionResult {
|
||||
return { success: true, message: `Clipboard set to "${text.slice(0, 50)}"` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastes clipboard content into the focused field.
|
||||
* Optionally taps coordinates first to focus the target field.
|
||||
*/
|
||||
function executePaste(action: ActionDecision): ActionResult {
|
||||
// If coordinates provided, tap to focus the target field first
|
||||
if (action.coordinates) {
|
||||
const coords = validateCoordinates(action.coordinates);
|
||||
if (coords) {
|
||||
console.log(`Focusing field: (${coords[0]}, ${coords[1]})`);
|
||||
runAdbCommand(["shell", "input", "tap", String(coords[0]), String(coords[1])]);
|
||||
Bun.sleepSync(300);
|
||||
}
|
||||
}
|
||||
console.log("Pasting from clipboard");
|
||||
runAdbCommand(["shell", "input", "keyevent", KEYCODE_PASTE]);
|
||||
return { success: true, message: "Pasted clipboard content" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the screen. Direction is from the user's perspective:
|
||||
* "down" = see more content below (swipe up), "up" = see content above (swipe down).
|
||||
*/
|
||||
const SCROLL_TO_SWIPE: Record<string, string> = {
|
||||
down: "up", // scroll down = swipe up
|
||||
up: "down", // scroll up = swipe down
|
||||
left: "right", // scroll left = swipe right
|
||||
right: "left", // scroll right = swipe left
|
||||
};
|
||||
|
||||
function executeScroll(action: ActionDecision): ActionResult {
|
||||
const direction = action.direction ?? "down";
|
||||
const swipeDir = SCROLL_TO_SWIPE[direction] ?? "up";
|
||||
const swipeCoords = getSwipeCoords();
|
||||
const coords = swipeCoords[swipeDir] ?? swipeCoords["up"];
|
||||
|
||||
console.log(`Scrolling ${direction}`);
|
||||
runAdbCommand([
|
||||
"shell", "input", "swipe",
|
||||
String(coords[0]), String(coords[1]),
|
||||
String(coords[2]), String(coords[3]),
|
||||
SWIPE_DURATION_MS,
|
||||
]);
|
||||
return { success: true, message: `Scrolled ${direction}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an arbitrary ADB shell command. Use sparingly for edge cases.
|
||||
*/
|
||||
|
||||
@@ -28,6 +28,7 @@ export const KEYCODE_DPAD_RIGHT = "22";
|
||||
export const KEYCODE_VOLUME_UP = "24";
|
||||
export const KEYCODE_VOLUME_DOWN = "25";
|
||||
export const KEYCODE_POWER = "26";
|
||||
export const KEYCODE_PASTE = "279";
|
||||
|
||||
// ===========================================
|
||||
// Default Screen Coordinates (for swipe actions)
|
||||
|
||||
+132
-19
@@ -32,9 +32,11 @@ import {
|
||||
getScreenResolution,
|
||||
getForegroundApp,
|
||||
initDeviceContext,
|
||||
sanitizeCoordinates,
|
||||
type ActionDecision,
|
||||
type ActionResult,
|
||||
} from "./actions.js";
|
||||
import { executeSkill } from "./skills.js";
|
||||
import {
|
||||
getLlmProvider,
|
||||
trimMessages,
|
||||
@@ -168,22 +170,47 @@ async function getDecisionStreaming(
|
||||
return parseJsonResponse(accumulated);
|
||||
}
|
||||
|
||||
/** Simple JSON parser with markdown fallback (duplicated from llm-providers for streaming path) */
|
||||
/**
|
||||
* Sanitizes raw LLM text so it can be parsed as JSON.
|
||||
* LLMs often put literal newlines inside JSON string values which breaks JSON.parse().
|
||||
* This replaces unescaped newlines inside strings with spaces.
|
||||
*/
|
||||
function sanitizeJsonText(raw: string): string {
|
||||
// Replace literal newlines/carriage returns with spaces — valid JSON
|
||||
// doesn't require newlines, and LLMs often embed them in string values.
|
||||
return raw.replace(/\n/g, " ").replace(/\r/g, " ");
|
||||
}
|
||||
|
||||
/** JSON parser with newline sanitization and markdown fallback (for streaming path) */
|
||||
function parseJsonResponse(text: string): ActionDecision {
|
||||
let decision: ActionDecision | null = null;
|
||||
|
||||
// First try raw text
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
decision = JSON.parse(text);
|
||||
} catch {
|
||||
const match = text.match(/\{[\s\S]*?\}/);
|
||||
if (match) {
|
||||
try {
|
||||
return JSON.parse(match[0]);
|
||||
} catch {
|
||||
// fall through
|
||||
// Try after sanitizing newlines
|
||||
try {
|
||||
decision = JSON.parse(sanitizeJsonText(text));
|
||||
} catch {
|
||||
// Try extracting JSON block from markdown or surrounding text
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try {
|
||||
decision = JSON.parse(sanitizeJsonText(match[0]));
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decision) {
|
||||
console.log(`Warning: Could not parse streamed response: ${text.slice(0, 200)}`);
|
||||
return { action: "wait", reason: "Failed to parse response, waiting" };
|
||||
}
|
||||
decision.coordinates = sanitizeCoordinates(decision.coordinates);
|
||||
return decision;
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
@@ -226,6 +253,8 @@ async function runAgent(goal: string, maxSteps?: number): Promise<void> {
|
||||
|
||||
let prevElements: UIElement[] = [];
|
||||
let stuckCount = 0;
|
||||
let recentActions: string[] = []; // Sliding window of action signatures for repetition detection
|
||||
let lastActionFeedback = ""; // Result of previous action to feed back to LLM
|
||||
|
||||
for (let step = 0; step < steps; step++) {
|
||||
console.log(`\n--- Step ${step + 1}/${steps} ---`);
|
||||
@@ -257,11 +286,31 @@ async function runAgent(goal: string, maxSteps?: number): Promise<void> {
|
||||
console.log(
|
||||
`Stuck for ${stuckCount} steps. Injecting recovery hint.`
|
||||
);
|
||||
diffContext +=
|
||||
`\nWARNING: You have been stuck for ${stuckCount} steps. ` +
|
||||
`The screen is NOT changing. Try a DIFFERENT action: ` +
|
||||
`swipe to scroll, press back, go home, or launch a different app.` +
|
||||
`\nYour plan is not working. Create a NEW plan with a different approach.`;
|
||||
|
||||
// Context-aware recovery hints based on what actions are failing
|
||||
const failingTypes = new Set(
|
||||
recentActions.slice(-stuckCount).map((a) => a.split("(")[0])
|
||||
);
|
||||
|
||||
let hint = `\nWARNING: You have been stuck for ${stuckCount} steps. The screen is NOT changing.`;
|
||||
|
||||
if (failingTypes.has("tap") || failingTypes.has("longpress")) {
|
||||
hint +=
|
||||
`\nYour tap/press actions are having NO EFFECT. Likely causes:` +
|
||||
`\n- The action SUCCEEDED SILENTLY (copy/share/like buttons often work without screen changes). If so, MOVE ON to the next step.` +
|
||||
`\n- The element is not actually interactive at those coordinates.` +
|
||||
`\n- USE "clipboard_set" to set clipboard text directly instead of UI copy buttons.` +
|
||||
`\n- Or just "type" the text directly in the target app — you already have the text from SCREEN_CONTEXT.`;
|
||||
}
|
||||
if (failingTypes.has("swipe") || failingTypes.has("scroll")) {
|
||||
hint +=
|
||||
`\nSwiping is having no effect — you may be at the end of scrollable content. Try interacting with visible elements or navigate with "back"/"home".`;
|
||||
}
|
||||
|
||||
hint +=
|
||||
`\nYour plan is NOT working. You MUST create a completely NEW plan with a different approach. Think about the underlying GOAL, not the specific steps that failed.`;
|
||||
|
||||
diffContext += hint;
|
||||
}
|
||||
} else {
|
||||
stuckCount = 0;
|
||||
@@ -269,13 +318,52 @@ async function runAgent(goal: string, maxSteps?: number): Promise<void> {
|
||||
}
|
||||
prevElements = elements;
|
||||
|
||||
// 3. Vision: capture screenshot based on VISION_MODE
|
||||
// 2B. Repetition detection (persists across screen changes — catches retry loops)
|
||||
if (recentActions.length >= 3) {
|
||||
const freq = new Map<string, number>();
|
||||
for (const a of recentActions) freq.set(a, (freq.get(a) ?? 0) + 1);
|
||||
const [topAction, topCount] = [...freq.entries()].reduce(
|
||||
(a, b) => (b[1] > a[1] ? b : a),
|
||||
["", 0]
|
||||
);
|
||||
if (topCount >= 3) {
|
||||
diffContext +=
|
||||
`\nREPETITION_ALERT: You have attempted "${topAction}" ${topCount} times in recent steps. ` +
|
||||
`This action is clearly NOT working — do NOT attempt it again.`;
|
||||
if (topAction.includes("tap") || topAction.includes("longpress")) {
|
||||
diffContext +=
|
||||
` ALTERNATIVES: (1) If you were copying text, the copy likely already succeeded — move on to the next step. ` +
|
||||
`(2) Use "clipboard_set" with the text from SCREEN_CONTEXT to set clipboard directly. ` +
|
||||
`(3) Use "type" to enter text directly in the target app. ` +
|
||||
`(4) Navigate away with "back" or "home" and try a different path.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2C. Drift detection — agent is floundering (swipe/back/wait/screenshot spam)
|
||||
if (recentActions.length >= 4) {
|
||||
const navigationActions = new Set(["swipe", "scroll", "back", "home", "wait"]);
|
||||
const navCount = recentActions
|
||||
.slice(-5)
|
||||
.filter((a) => navigationActions.has(a.split("(")[0])).length;
|
||||
if (navCount >= 4) {
|
||||
diffContext +=
|
||||
`\nDRIFT_WARNING: Your last ${navCount} actions were all navigation/waiting (swipe, back, wait, screenshot) with no direct interaction. ` +
|
||||
`You are not making progress. STOP scrolling/navigating and take a DIRECT action: ` +
|
||||
`tap a specific button from SCREEN_CONTEXT, use "type" to enter text, or use "clipboard_set". ` +
|
||||
`If you need to submit a query in a chat app, find the Send button in SCREEN_CONTEXT and tap it.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Vision: capture screenshot based on VISION_MODE or stuck recovery
|
||||
let screenshotBase64: string | null = null;
|
||||
let visionContext = "";
|
||||
|
||||
const isStuckVision = stuckCount >= 2; // Send screenshot after 2 unchanged steps
|
||||
const shouldCaptureVision =
|
||||
Config.VISION_MODE === "always" ||
|
||||
(Config.VISION_MODE === "fallback" && elements.length === 0);
|
||||
(Config.VISION_MODE === "fallback" && elements.length === 0) ||
|
||||
isStuckVision;
|
||||
|
||||
if (shouldCaptureVision) {
|
||||
screenshotBase64 = captureScreenshotBase64();
|
||||
@@ -285,9 +373,16 @@ async function runAgent(goal: string, maxSteps?: number): Promise<void> {
|
||||
"A screenshot has been captured. The screen likely contains custom-drawn " +
|
||||
"content (game, WebView, or Flutter). Try using coordinate-based taps on " +
|
||||
"common UI positions, or use 'back'/'home' to navigate away.";
|
||||
} else if (isStuckVision) {
|
||||
visionContext =
|
||||
"\n\nVISION_ASSIST: You have been stuck — a screenshot is attached. " +
|
||||
"Use the screenshot to VISUALLY identify the correct field positions, " +
|
||||
"buttons, and layout. The accessibility tree may be misleading about " +
|
||||
"which field is which. Trust what you SEE in the screenshot over the " +
|
||||
"element coordinates when they conflict.";
|
||||
}
|
||||
if (screenshotBase64 && llm.capabilities.supportsImages) {
|
||||
console.log("Sending screenshot to LLM");
|
||||
console.log(isStuckVision ? "Stuck — sending screenshot for visual assist" : "Sending screenshot to LLM");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,8 +390,11 @@ async function runAgent(goal: string, maxSteps?: number): Promise<void> {
|
||||
const foregroundLine = foregroundApp
|
||||
? `FOREGROUND_APP: ${foregroundApp}\n\n`
|
||||
: "";
|
||||
const actionFeedbackLine = lastActionFeedback
|
||||
? `LAST_ACTION_RESULT: ${lastActionFeedback}\n\n`
|
||||
: "";
|
||||
const textContent =
|
||||
`GOAL: ${goal}\n\n${foregroundLine}SCREEN_CONTEXT:\n${screenContext}${diffContext}${visionContext}`;
|
||||
`GOAL: ${goal}\n\n${foregroundLine}${actionFeedbackLine}SCREEN_CONTEXT:\n${screenContext}${diffContext}${visionContext}`;
|
||||
|
||||
// Build content parts (text + optional image)
|
||||
const userContent: ContentPart[] = [{ type: "text", text: textContent }];
|
||||
@@ -343,11 +441,16 @@ async function runAgent(goal: string, maxSteps?: number): Promise<void> {
|
||||
content: JSON.stringify(decision),
|
||||
});
|
||||
|
||||
// 6. Action: Execute the decision
|
||||
// 6. Action: Execute the decision (multi-step actions or basic actions)
|
||||
const MULTI_STEP_ACTIONS = ["read_screen", "submit_message", "copy_visible_text", "wait_for_content", "find_and_tap", "compose_email"];
|
||||
const actionStart = performance.now();
|
||||
let result: ActionResult;
|
||||
try {
|
||||
result = executeAction(decision);
|
||||
if (MULTI_STEP_ACTIONS.includes(decision.action)) {
|
||||
result = executeSkill(decision, elements);
|
||||
} else {
|
||||
result = executeAction(decision);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Action Error: ${(err as Error).message}`);
|
||||
result = { success: false, message: (err as Error).message };
|
||||
@@ -366,6 +469,16 @@ async function runAgent(goal: string, maxSteps?: number): Promise<void> {
|
||||
Math.round(actionLatency)
|
||||
);
|
||||
|
||||
// Track action signature for repetition detection
|
||||
const actionSig = decision.coordinates
|
||||
? `${decision.action}(${decision.coordinates.join(",")})`
|
||||
: decision.action;
|
||||
recentActions.push(actionSig);
|
||||
if (recentActions.length > 8) recentActions.shift();
|
||||
|
||||
// Capture action result feedback for next iteration
|
||||
lastActionFeedback = `${actionSig} → ${result.success ? "OK" : "FAILED"}: ${result.message}`;
|
||||
|
||||
console.log(`Messages in context: ${trimmed.length}`);
|
||||
|
||||
// 7. Check for goal completion
|
||||
|
||||
+132
-32
@@ -13,8 +13,9 @@ import {
|
||||
InvokeModelCommand,
|
||||
InvokeModelWithResponseStreamCommand,
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import { generateText, streamText } from "ai";
|
||||
import { generateText, streamText, generateObject, streamObject } from "ai";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Config } from "./config.js";
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
BEDROCK_ANTHROPIC_MODELS,
|
||||
BEDROCK_META_MODELS,
|
||||
} from "./constants.js";
|
||||
import type { ActionDecision } from "./actions.js";
|
||||
import { sanitizeCoordinates, type ActionDecision } from "./actions.js";
|
||||
|
||||
// ===========================================
|
||||
// System Prompt — all 15 actions + planning
|
||||
@@ -33,10 +34,11 @@ export const SYSTEM_PROMPT = `You are an Android Driver Agent. Your job is to ac
|
||||
You will receive:
|
||||
1. GOAL — the user's task.
|
||||
2. FOREGROUND_APP — the currently active app package and activity.
|
||||
3. SCREEN_CONTEXT — JSON array of interactive UI elements with coordinates and states.
|
||||
4. SCREENSHOT — an image of the current screen (when available).
|
||||
5. SCREEN_CHANGE — what changed since your last action (or if the screen is stuck).
|
||||
6. VISION_FALLBACK — present when the accessibility tree is empty (custom UI / WebView).
|
||||
3. LAST_ACTION_RESULT — the outcome of your previous action (success/failure and details).
|
||||
4. SCREEN_CONTEXT — JSON array of interactive UI elements with coordinates and states.
|
||||
5. SCREENSHOT — an image of the current screen (when available).
|
||||
6. SCREEN_CHANGE — what changed since your last action (or if the screen is stuck).
|
||||
7. VISION_FALLBACK — present when the accessibility tree is empty (custom UI / WebView).
|
||||
|
||||
Previous conversation turns contain your earlier observations and actions (multi-turn memory).
|
||||
|
||||
@@ -59,16 +61,16 @@ Example:
|
||||
AVAILABLE ACTIONS (15 total)
|
||||
═══════════════════════════════════════════
|
||||
|
||||
Navigation:
|
||||
{"action": "tap", "coordinates": [x, y], "reason": "..."}
|
||||
{"action": "longpress", "coordinates": [x, y], "reason": "..."}
|
||||
{"action": "swipe", "direction": "up|down|left|right", "reason": "..."}
|
||||
Navigation (coordinates MUST be a JSON array of TWO separate integers [x, y] — never concatenate them):
|
||||
{"action": "tap", "coordinates": [540, 1200], "reason": "..."}
|
||||
{"action": "longpress", "coordinates": [540, 1200], "reason": "..."}
|
||||
{"action": "scroll", "direction": "up|down|left|right", "reason": "Scroll to see more content (down=below, up=above)"}
|
||||
{"action": "enter", "reason": "Press Enter/submit"}
|
||||
{"action": "back", "reason": "Navigate back"}
|
||||
{"action": "home", "reason": "Go to home screen"}
|
||||
|
||||
Text Input:
|
||||
{"action": "type", "text": "Hello World", "reason": "..."}
|
||||
Text Input (ALWAYS include coordinates to focus the correct field before typing):
|
||||
{"action": "type", "coordinates": [540, 648], "text": "Hello World", "reason": "..."}
|
||||
{"action": "clear", "reason": "Clear current text field before typing"}
|
||||
|
||||
App Control:
|
||||
@@ -77,16 +79,26 @@ App Control:
|
||||
{"action": "launch", "package": "com.whatsapp", "uri": "content://media/external/images/1", "extras": {"android.intent.extra.TEXT": "Check this"}, "reason": "Share image to WhatsApp"}
|
||||
|
||||
Data:
|
||||
{"action": "screenshot", "reason": "Capture current screen"}
|
||||
{"action": "screenshot", "filename": "order_confirmation.png", "reason": "Save proof"}
|
||||
{"action": "clipboard_get", "reason": "Read clipboard contents"}
|
||||
{"action": "clipboard_set", "text": "copied text", "reason": "Set clipboard"}
|
||||
{"action": "paste", "coordinates": [540, 804], "reason": "Paste clipboard into focused field"}
|
||||
|
||||
System:
|
||||
{"action": "shell", "command": "am force-stop com.app.broken", "reason": "Kill crashed app"}
|
||||
{"action": "wait", "reason": "Wait for screen to load"}
|
||||
{"action": "done", "reason": "Task is complete"}
|
||||
|
||||
Multi-Step Actions (PREFER these over basic actions when applicable):
|
||||
{"action": "read_screen", "reason": "Scroll through entire page, collect ALL text, copy to clipboard"}
|
||||
{"action": "submit_message", "reason": "Find and tap Send button, wait for response"}
|
||||
{"action": "copy_visible_text", "reason": "Copy all visible text to clipboard"}
|
||||
{"action": "copy_visible_text", "query": "search term", "reason": "Copy matching text to clipboard"}
|
||||
{"action": "wait_for_content", "reason": "Wait for new content to appear"}
|
||||
{"action": "find_and_tap", "query": "Button Label", "reason": "Find element by text and tap it"}
|
||||
{"action": "compose_email", "query": "recipient@email.com", "reason": "Fill email To+Body, pastes clipboard into body"}
|
||||
{"action": "compose_email", "query": "recipient@email.com", "text": "body", "reason": "Fill email with specific body"}
|
||||
NOTE: compose_email REQUIRES "query" = recipient email. "text" is optional body (clipboard used if empty).
|
||||
|
||||
═══════════════════════════════════════════
|
||||
ELEMENT PROPERTIES YOU WILL SEE
|
||||
═══════════════════════════════════════════
|
||||
@@ -107,20 +119,62 @@ CRITICAL RULES
|
||||
═══════════════════════════════════════════
|
||||
|
||||
1. DISABLED ELEMENTS: If "enabled": false, DO NOT tap or interact with it. Find an alternative.
|
||||
2. TEXT INPUT: If "editable": true, use "clear" first if field has existing text, then "type".
|
||||
2. TEXT INPUT: ALWAYS include "coordinates" with "type" to focus the correct field. Without coordinates, text goes into whatever field was last focused — which may be WRONG. If "editable": true, use "clear" first if field has existing text, then "type".
|
||||
3. ALREADY TYPED: Check your previous actions. Do NOT re-type text you already entered.
|
||||
4. REPETITION: Do NOT tap the same coordinates twice in a row. If it didn't work, try something else.
|
||||
5. STUCK: If SCREEN_CHANGE says "NOT changed", your last action had no effect. Change strategy.
|
||||
6. APP LAUNCH: Use "launch" to directly open apps instead of hunting for icons on the home screen.
|
||||
7. SCREENSHOTS: Use "screenshot" to capture proof of completed tasks (order confirmations, etc).
|
||||
7. READ PAGES: Use "read_screen" to collect all text from a page (search results, articles, feeds). It scrolls automatically and copies everything to clipboard.
|
||||
8. LONG PRESS: Use "longpress" when you see "longClickable": true (context menus, copy/paste, etc).
|
||||
9. SCROLLING: If the item you need isn't visible, "swipe" up/down to scroll and find it.
|
||||
9. SCROLLING: If the item you need isn't visible, use "scroll" with direction "down" to see more below, or "up" for above.
|
||||
10. MULTI-APP: To switch apps, use "home" then "launch" the next app. Or use "back" to return.
|
||||
11. PASSWORDS: Never log or output the text of password fields.
|
||||
12. DONE: Say "done" as soon as the goal is achieved. Don't keep acting after success.
|
||||
13. SEARCH: After typing in a search field, use "enter" to submit the search.
|
||||
13. SUBMIT IN CHAT APPS: Use "submit_message" action instead of "enter" in chat apps. It finds and taps the Send button, waits for a response, and reports new content. Only use "enter" in search bars or web forms.
|
||||
14. SHARE: To send files/images between apps, use "launch" with uri + extras for Android intents.
|
||||
15. CLEANUP: If a popup/ad appears, dismiss it with "back" or tap the close button, then continue.`;
|
||||
15. CLEANUP: If a popup/ad appears, dismiss it with "back" or tap the close button, then continue.
|
||||
16. COPY-PASTE: PREFERRED: Use "copy_visible_text" action to copy text to clipboard programmatically — this bypasses unreliable UI Copy buttons entirely. Then switch apps and "paste".
|
||||
ALTERNATIVE: Use "clipboard_set" with the text you see in SCREEN_CONTEXT, then switch apps and "paste".
|
||||
FALLBACK: Just "type" the text directly into the target app field.
|
||||
NEVER type a vague description — always use the actual text content.
|
||||
17. COORDINATES: ALWAYS use coordinates from SCREEN_CONTEXT elements (the "center" field). NEVER estimate or guess coordinates from screenshots — they are inaccurate. Screenshots help you understand the layout; SCREEN_CONTEXT provides the correct tap targets.
|
||||
18. BACK IS DESTRUCTIVE: NEVER use "back" to leave an app while you have a task in progress within it. You will LOSE all progress (typed text, loading responses, navigation state). Try all other in-app approaches first. Only use "back" after 5+ failed attempts within the app.
|
||||
19. LEARN FROM HISTORY: Before choosing an action, check your earlier turns. If "enter" failed to submit a query before, do NOT try "enter" again — find and tap the Send button. If specific coordinates didn't work, try different ones. Never repeat a strategy that already failed in this session.
|
||||
20. EMAIL COMPOSE: ALWAYS use "compose_email" action when filling email fields. It fills To, Subject, and Body in the correct order. Pass the recipient email in "query" and body text in "text" (or it pastes from clipboard). NEVER manually type/paste into email fields — you WILL put it in the wrong field.
|
||||
|
||||
═══════════════════════════════════════════
|
||||
ADAPTIVE PROBLEM-SOLVING
|
||||
═══════════════════════════════════════════
|
||||
|
||||
NEVER REPEAT A FAILING ACTION more than once. If an action doesn't produce the expected result after 1 attempt, STOP and try a completely different approach.
|
||||
|
||||
SILENT SUCCESSES: Some actions succeed WITHOUT changing the screen:
|
||||
- Tapping "Copy", "Share", "Like", or "Bookmark" buttons often works silently.
|
||||
- If you tapped a Copy button and the screen didn't change, it likely WORKED. Move on to the next step instead of retrying.
|
||||
|
||||
SCREEN_CONTEXT IS YOUR DATA: The text in SCREEN_CONTEXT elements is data you already have. You can use it directly in:
|
||||
- "clipboard_set" — to set clipboard contents programmatically (more reliable than UI copy)
|
||||
- "type" — to enter text directly into any field
|
||||
You do NOT need to "copy" text via UI — you already have it from SCREEN_CONTEXT.
|
||||
|
||||
GOAL-ORIENTED THINKING: Focus on WHAT you need to accomplish, not on rigidly following planned steps. If a step fails, ask: "What was the PURPOSE of this step?" and find another way.
|
||||
- Goal says "copy and send as email"? If Copy fails, use clipboard_set with SCREEN_CONTEXT text, or type it directly in the email.
|
||||
- Goal says "search for X"? If enter doesn't submit, look for and tap the send/search button.
|
||||
- Goal says "open app X"? Use "launch" with package name instead of hunting for icons.
|
||||
|
||||
SMART DECISION PRIORITIES: When multiple approaches can achieve the same result, prefer:
|
||||
1. Programmatic actions (clipboard_set, launch, shell) — most reliable, no UI dependency.
|
||||
2. Direct input (type, paste, enter) — reliable when field is focused.
|
||||
3. UI button interactions (tap, longpress) — LEAST reliable, depends on correct coordinates.
|
||||
Before choosing an action, ask: "Is there a simpler, more direct way to do this?"
|
||||
|
||||
PATIENCE WITH LOADING: AI chatbots (ChatGPT, Gemini, Claude) take 5-15 seconds to generate responses. After submitting a query, use "wait" 2-3 times before assuming it failed. Do NOT start scrolling or navigating away prematurely.
|
||||
|
||||
ESCAPE STUCK LOOPS — when stuck, try in this priority order:
|
||||
1. The action may have already succeeded silently — MOVE ON to the next task step.
|
||||
2. Use programmatic alternatives (clipboard_set, type, shell, launch with URI).
|
||||
3. Try a completely different UI element or interaction method.
|
||||
4. Navigate away (back, home) ONLY as an absolute last resort — this loses progress.`;
|
||||
|
||||
// ===========================================
|
||||
// Chat Message Types (Phase 4A)
|
||||
@@ -272,6 +326,25 @@ class OpenAIProvider implements LLMProvider {
|
||||
// OpenRouter Provider (Vercel AI SDK)
|
||||
// ===========================================
|
||||
|
||||
/** Zod schema for structured LLM output — guarantees valid JSON */
|
||||
const actionDecisionSchema = z.object({
|
||||
think: z.string().optional().describe("Your reasoning about the current screen state and what to do next"),
|
||||
plan: z.array(z.string()).optional().describe("3-5 high-level steps to achieve the goal"),
|
||||
planProgress: z.string().optional().describe("Which plan step you are currently on"),
|
||||
action: z.string().describe("The action to take: tap, type, scroll, enter, back, home, wait, done, longpress, launch, clear, clipboard_get, clipboard_set, paste, shell, read_screen, submit_message, copy_visible_text, wait_for_content, find_and_tap, compose_email"),
|
||||
coordinates: z.tuple([z.number(), z.number()]).optional().describe("Target field as [x, y] — used by tap, longpress, type, and paste"),
|
||||
text: z.string().optional().describe("Text to type, clipboard text, or email body for compose_email"),
|
||||
direction: z.string().optional().describe("Scroll direction: up, down, left, right"),
|
||||
reason: z.string().optional().describe("Why you chose this action"),
|
||||
package: z.string().optional().describe("App package name for launch action"),
|
||||
activity: z.string().optional().describe("Activity name for launch action"),
|
||||
uri: z.string().optional().describe("URI for launch action"),
|
||||
extras: z.record(z.string(), z.string()).optional().describe("Intent extras for launch action"),
|
||||
command: z.string().optional().describe("Shell command to run"),
|
||||
filename: z.string().optional().describe("Screenshot filename"),
|
||||
query: z.string().optional().describe("Email address for compose_email (REQUIRED), search term for find_and_tap (REQUIRED), or filter for copy_visible_text"),
|
||||
});
|
||||
|
||||
class OpenRouterProvider implements LLMProvider {
|
||||
private openrouter: ReturnType<typeof createOpenRouter>;
|
||||
private model: string;
|
||||
@@ -313,24 +386,34 @@ class OpenRouterProvider implements LLMProvider {
|
||||
|
||||
async getDecision(messages: ChatMessage[]): Promise<ActionDecision> {
|
||||
const { system, messages: converted } = this.toVercelMessages(messages);
|
||||
const result = await generateText({
|
||||
const { object } = await generateObject({
|
||||
model: this.openrouter.chat(this.model),
|
||||
schema: actionDecisionSchema,
|
||||
system,
|
||||
messages: converted as any,
|
||||
});
|
||||
return parseJsonResponse(result.text);
|
||||
// Sanitize coordinates from structured output
|
||||
const decision = object as ActionDecision;
|
||||
decision.coordinates = sanitizeCoordinates(decision.coordinates);
|
||||
return decision;
|
||||
}
|
||||
|
||||
async *getDecisionStream(messages: ChatMessage[]): AsyncIterable<string> {
|
||||
const { system, messages: converted } = this.toVercelMessages(messages);
|
||||
const result = streamText({
|
||||
const { partialObjectStream } = streamObject({
|
||||
model: this.openrouter.chat(this.model),
|
||||
schema: actionDecisionSchema,
|
||||
system,
|
||||
messages: converted as any,
|
||||
});
|
||||
for await (const chunk of result.textStream) {
|
||||
yield chunk;
|
||||
// Accumulate partial objects and yield the final complete one as JSON
|
||||
let lastObject: any = {};
|
||||
for await (const partial of partialObjectStream) {
|
||||
lastObject = partial;
|
||||
// Yield a dot for progress indication (streaming UI feedback)
|
||||
yield ".";
|
||||
}
|
||||
yield JSON.stringify(lastObject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,22 +585,39 @@ class BedrockProvider implements LLMProvider {
|
||||
// Shared JSON Parsing
|
||||
// ===========================================
|
||||
|
||||
/**
|
||||
* Sanitizes raw LLM text so it can be parsed as JSON.
|
||||
* LLMs often put literal newlines inside JSON string values which breaks JSON.parse().
|
||||
*/
|
||||
function sanitizeJsonText(raw: string): string {
|
||||
return raw.replace(/\n/g, " ").replace(/\r/g, " ");
|
||||
}
|
||||
|
||||
function parseJsonResponse(text: string): ActionDecision {
|
||||
let decision: ActionDecision | null = null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
decision = JSON.parse(text);
|
||||
} catch {
|
||||
// Try to extract JSON from markdown code blocks or mixed text
|
||||
const match = text.match(/\{[\s\S]*?\}/);
|
||||
if (match) {
|
||||
try {
|
||||
return JSON.parse(match[0]);
|
||||
} catch {
|
||||
// fall through
|
||||
try {
|
||||
decision = JSON.parse(sanitizeJsonText(text));
|
||||
} catch {
|
||||
// Try to extract JSON from markdown code blocks or mixed text
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try {
|
||||
decision = JSON.parse(sanitizeJsonText(match[0]));
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!decision) {
|
||||
console.log(`Warning: Could not parse LLM response: ${text.slice(0, 200)}`);
|
||||
return { action: "wait", reason: "Failed to parse response, waiting" };
|
||||
}
|
||||
decision.coordinates = sanitizeCoordinates(decision.coordinates);
|
||||
return decision;
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Skills module for DroidClaw.
|
||||
* Multi-step smart actions that reduce LLM decision points and eliminate
|
||||
* entire categories of errors (coordinate guessing, wrong submit buttons, etc.)
|
||||
*
|
||||
* Skills:
|
||||
* submit_message — Find and tap the Send/Submit button in chat apps
|
||||
* copy_visible_text — Read text from screen elements and set clipboard programmatically
|
||||
* wait_for_content — Wait for new content to appear (AI responses, page loads)
|
||||
* find_and_tap — Find an element by text label and tap it
|
||||
* compose_email — Fill email fields in correct order (To, Subject, Body)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { Config } from "./config.js";
|
||||
import { runAdbCommand, getSwipeCoords, type ActionDecision, type ActionResult } from "./actions.js";
|
||||
import { getInteractiveElements, type UIElement } from "./sanitizer.js";
|
||||
import { SWIPE_DURATION_MS } from "./constants.js";
|
||||
|
||||
/**
|
||||
* Routes a skill action to the appropriate skill function.
|
||||
*/
|
||||
export function executeSkill(
|
||||
decision: ActionDecision,
|
||||
elements: UIElement[]
|
||||
): ActionResult {
|
||||
const skill = decision.skill ?? decision.action;
|
||||
console.log(`Executing multi-step action: ${skill}`);
|
||||
|
||||
switch (skill) {
|
||||
case "read_screen":
|
||||
return readScreen(elements);
|
||||
case "submit_message":
|
||||
return submitMessage(elements);
|
||||
case "copy_visible_text":
|
||||
return copyVisibleText(decision, elements);
|
||||
case "wait_for_content":
|
||||
return waitForContent(elements);
|
||||
case "find_and_tap":
|
||||
return findAndTap(decision, elements);
|
||||
case "compose_email":
|
||||
return composeEmail(decision, elements);
|
||||
default:
|
||||
return { success: false, message: `Unknown skill: ${skill}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Helper: re-scan screen
|
||||
// ===========================================
|
||||
|
||||
/**
|
||||
* Sets clipboard text via ADB with proper shell escaping.
|
||||
* ADB shell joins args into a single string, so parentheses/quotes break it.
|
||||
* Wrapping in single quotes and escaping internal quotes fixes this.
|
||||
*/
|
||||
function safeClipboardSet(text: string): void {
|
||||
const escaped = text.replaceAll("'", "'\\''");
|
||||
runAdbCommand(["shell", `cmd clipboard set-text '${escaped}'`]);
|
||||
}
|
||||
|
||||
function rescanScreen(): UIElement[] {
|
||||
try {
|
||||
runAdbCommand(["shell", "uiautomator", "dump", Config.SCREEN_DUMP_PATH]);
|
||||
runAdbCommand(["pull", Config.SCREEN_DUMP_PATH, Config.LOCAL_DUMP_PATH]);
|
||||
} catch {
|
||||
console.log("Warning: ADB screen capture failed during skill re-scan.");
|
||||
return [];
|
||||
}
|
||||
if (!existsSync(Config.LOCAL_DUMP_PATH)) return [];
|
||||
const xmlContent = readFileSync(Config.LOCAL_DUMP_PATH, "utf-8");
|
||||
return getInteractiveElements(xmlContent);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Skill 0: read_screen (scroll + collect all text)
|
||||
// ===========================================
|
||||
|
||||
function readScreen(elements: UIElement[]): ActionResult {
|
||||
const allTexts: string[] = [];
|
||||
const seenTexts = new Set<string>();
|
||||
|
||||
function collectTexts(els: UIElement[]): number {
|
||||
let added = 0;
|
||||
for (const el of els) {
|
||||
if (el.text && !seenTexts.has(el.text)) {
|
||||
seenTexts.add(el.text);
|
||||
allTexts.push(el.text);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// 1. Collect from initial screen
|
||||
collectTexts(elements);
|
||||
|
||||
// 2. Scroll down and collect until no new content
|
||||
const swipeCoords = getSwipeCoords();
|
||||
const upCoords = swipeCoords["up"]; // swipe up = scroll down = see more below
|
||||
const maxScrolls = 5;
|
||||
let scrollsDone = 0;
|
||||
|
||||
for (let i = 0; i < maxScrolls; i++) {
|
||||
runAdbCommand([
|
||||
"shell", "input", "swipe",
|
||||
String(upCoords[0]), String(upCoords[1]),
|
||||
String(upCoords[2]), String(upCoords[3]),
|
||||
SWIPE_DURATION_MS,
|
||||
]);
|
||||
Bun.sleepSync(1500);
|
||||
scrollsDone++;
|
||||
|
||||
const newElements = rescanScreen();
|
||||
const added = collectTexts(newElements);
|
||||
console.log(`read_screen: Scroll ${scrollsDone} — found ${added} new text elements`);
|
||||
|
||||
if (added === 0) break;
|
||||
}
|
||||
|
||||
const combinedText = allTexts.join("\n");
|
||||
|
||||
// 3. Copy to clipboard for easy access
|
||||
if (combinedText.length > 0) {
|
||||
safeClipboardSet(combinedText);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Read ${allTexts.length} text elements across ${scrollsDone} scrolls (${combinedText.length} chars), copied to clipboard`,
|
||||
data: combinedText,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Skill 1: submit_message
|
||||
// ===========================================
|
||||
|
||||
const SEND_BUTTON_PATTERN = /send|submit|post|arrow|paper.?plane/i;
|
||||
|
||||
function submitMessage(elements: UIElement[]): ActionResult {
|
||||
// 1. Search for Send/Submit button by text
|
||||
let candidates = elements.filter(
|
||||
(el) =>
|
||||
el.enabled &&
|
||||
(el.clickable || el.action === "tap") &&
|
||||
(SEND_BUTTON_PATTERN.test(el.text) || SEND_BUTTON_PATTERN.test(el.id))
|
||||
);
|
||||
|
||||
// 2. If no text match, look for clickable elements in the bottom 20% of screen
|
||||
// near the right side (common Send button position)
|
||||
if (candidates.length === 0) {
|
||||
const screenBottom = elements
|
||||
.filter((el) => el.enabled && el.clickable)
|
||||
.sort((a, b) => b.center[1] - a.center[1]);
|
||||
|
||||
// Take elements in the bottom 20% by Y coordinate
|
||||
if (screenBottom.length > 0) {
|
||||
const maxY = screenBottom[0].center[1];
|
||||
const threshold = maxY * 0.8;
|
||||
candidates = screenBottom.filter((el) => el.center[1] >= threshold);
|
||||
// Prefer rightmost element (Send buttons are usually on the right)
|
||||
candidates.sort((a, b) => b.center[0] - a.center[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Could not find a Send/Submit button on screen",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Tap the best match
|
||||
const target = candidates[0];
|
||||
const [x, y] = target.center;
|
||||
console.log(
|
||||
`submit_message: Tapping "${target.text}" at (${x}, ${y})`
|
||||
);
|
||||
runAdbCommand(["shell", "input", "tap", String(x), String(y)]);
|
||||
|
||||
// 4. Wait for response to generate
|
||||
console.log("submit_message: Waiting 6s for response...");
|
||||
Bun.sleepSync(6000);
|
||||
|
||||
// 5. Re-scan screen and check for new content
|
||||
const newElements = rescanScreen();
|
||||
const originalTexts = new Set(elements.map((el) => el.text).filter(Boolean));
|
||||
const newTexts = newElements
|
||||
.map((el) => el.text)
|
||||
.filter((t) => t && !originalTexts.has(t));
|
||||
|
||||
if (newTexts.length > 0) {
|
||||
const summary = newTexts.slice(0, 3).join("; ");
|
||||
return {
|
||||
success: true,
|
||||
message: `Tapped "${target.text}" and new content appeared: ${summary}`,
|
||||
data: summary,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Tapped "${target.text}" at (${x}, ${y}). No new content yet — may still be loading.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Skill 2: copy_visible_text
|
||||
// ===========================================
|
||||
|
||||
function copyVisibleText(
|
||||
decision: ActionDecision,
|
||||
elements: UIElement[]
|
||||
): ActionResult {
|
||||
// 1. Filter for readable text elements
|
||||
let textElements = elements.filter(
|
||||
(el) => el.text && el.action === "read"
|
||||
);
|
||||
|
||||
// 2. If query provided, filter to matching elements
|
||||
if (decision.query) {
|
||||
const query = decision.query.toLowerCase();
|
||||
textElements = textElements.filter((el) =>
|
||||
el.text.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// If no read-only text, include all elements with text
|
||||
if (textElements.length === 0) {
|
||||
textElements = elements.filter((el) => el.text);
|
||||
if (decision.query) {
|
||||
const query = decision.query.toLowerCase();
|
||||
textElements = textElements.filter((el) =>
|
||||
el.text.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (textElements.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: decision.query
|
||||
? `No text matching "${decision.query}" found on screen`
|
||||
: "No readable text found on screen",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Sort by vertical position (top to bottom)
|
||||
textElements.sort((a, b) => a.center[1] - b.center[1]);
|
||||
|
||||
// 4. Concatenate text
|
||||
const combinedText = textElements.map((el) => el.text).join("\n");
|
||||
|
||||
// 5. Set clipboard programmatically
|
||||
console.log(
|
||||
`copy_visible_text: Copying ${textElements.length} text elements (${combinedText.length} chars)`
|
||||
);
|
||||
safeClipboardSet(combinedText);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Copied ${textElements.length} text elements to clipboard (${combinedText.length} chars)`,
|
||||
data: combinedText,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Skill 3: wait_for_content
|
||||
// ===========================================
|
||||
|
||||
function waitForContent(elements: UIElement[]): ActionResult {
|
||||
// 1. Record current element texts
|
||||
const originalTexts = new Set(elements.map((el) => el.text).filter(Boolean));
|
||||
|
||||
// 2. Poll up to 5 times (3s intervals = 15s max)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
console.log(
|
||||
`wait_for_content: Waiting 3s... (attempt ${i + 1}/5)`
|
||||
);
|
||||
Bun.sleepSync(3000);
|
||||
|
||||
// Re-scan screen
|
||||
const newElements = rescanScreen();
|
||||
const newTexts = newElements
|
||||
.map((el) => el.text)
|
||||
.filter((t) => t && !originalTexts.has(t));
|
||||
|
||||
// Check if meaningful new content appeared (>20 chars total)
|
||||
const totalNewChars = newTexts.reduce((sum, t) => sum + t.length, 0);
|
||||
if (totalNewChars > 20) {
|
||||
const summary = newTexts.slice(0, 5).join("; ");
|
||||
console.log(
|
||||
`wait_for_content: Found ${newTexts.length} new text elements (${totalNewChars} chars)`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: `New content appeared after ${(i + 1) * 3}s: ${summary}`,
|
||||
data: summary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "No new content appeared after 15s",
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Skill 4: find_and_tap
|
||||
// ===========================================
|
||||
|
||||
function findAndTap(
|
||||
decision: ActionDecision,
|
||||
elements: UIElement[]
|
||||
): ActionResult {
|
||||
const query = decision.query;
|
||||
if (!query) {
|
||||
return { success: false, message: "find_and_tap requires a query" };
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// 1. Search elements for text matching query
|
||||
const matches = elements.filter(
|
||||
(el) => el.text && el.text.toLowerCase().includes(queryLower)
|
||||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
// Return available element texts to help the LLM
|
||||
const available = elements
|
||||
.filter((el) => el.text)
|
||||
.map((el) => el.text)
|
||||
.slice(0, 15);
|
||||
return {
|
||||
success: false,
|
||||
message: `No element matching "${query}" found. Available: ${available.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Score matches
|
||||
const scored = matches.map((el) => {
|
||||
let score = 0;
|
||||
if (el.enabled) score += 10;
|
||||
if (el.clickable || el.longClickable) score += 5;
|
||||
if (el.text.toLowerCase() === queryLower) score += 20; // exact match
|
||||
else score += 5; // partial match
|
||||
return { el, score };
|
||||
});
|
||||
|
||||
// 3. Pick highest-scoring match
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const best = scored[0].el;
|
||||
const [x, y] = best.center;
|
||||
|
||||
// 4. Tap it
|
||||
console.log(
|
||||
`find_and_tap: Tapping "${best.text}" at (${x}, ${y}) [score: ${scored[0].score}]`
|
||||
);
|
||||
runAdbCommand(["shell", "input", "tap", String(x), String(y)]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found and tapped "${best.text}" at (${x}, ${y})`,
|
||||
data: best.text,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Skill 5: compose_email
|
||||
// ===========================================
|
||||
|
||||
/** Patterns to identify email compose fields by resource ID */
|
||||
const TO_FIELD_PATTERN = /to|recipient/i;
|
||||
const SUBJECT_FIELD_PATTERN = /subject/i;
|
||||
const BODY_FIELD_PATTERN = /body|compose_area|compose_edit|message_content/i;
|
||||
|
||||
/** Patterns to identify fields by hint text */
|
||||
const TO_HINT_PATTERN = /^to$|recipient|email.?address/i;
|
||||
const SUBJECT_HINT_PATTERN = /subject/i;
|
||||
const BODY_HINT_PATTERN = /compose|body|message|write/i;
|
||||
|
||||
/**
|
||||
* Finds an editable field matching the given ID and hint patterns.
|
||||
* Falls back to positional matching if patterns don't match.
|
||||
*/
|
||||
function findEmailField(
|
||||
editables: UIElement[],
|
||||
idPattern: RegExp,
|
||||
hintPattern: RegExp
|
||||
): UIElement | undefined {
|
||||
// Try resource ID first (most reliable)
|
||||
const byId = editables.find((el) => idPattern.test(el.id));
|
||||
if (byId) return byId;
|
||||
// Try hint text
|
||||
const byHint = editables.find((el) => el.hint && hintPattern.test(el.hint));
|
||||
if (byHint) return byHint;
|
||||
// Try visible label/text
|
||||
const byText = editables.find((el) => idPattern.test(el.text));
|
||||
if (byText) return byText;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Try to extract an email address from a string */
|
||||
function extractEmail(text: string): string | null {
|
||||
const match = text.match(/[\w.+-]+@[\w.-]+\.\w{2,}/);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
function composeEmail(
|
||||
decision: ActionDecision,
|
||||
elements: UIElement[]
|
||||
): ActionResult {
|
||||
// Resolve email address: try query first, then extract from text
|
||||
let emailAddress = decision.query;
|
||||
const bodyContent = decision.text;
|
||||
|
||||
if (!emailAddress && bodyContent) {
|
||||
const extracted = extractEmail(bodyContent);
|
||||
if (extracted) {
|
||||
emailAddress = extracted;
|
||||
console.log(`compose_email: Extracted email "${emailAddress}" from text field`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!emailAddress) {
|
||||
return {
|
||||
success: false,
|
||||
message: "compose_email requires query (email address). Example: {\"action\": \"compose_email\", \"query\": \"user@example.com\"}",
|
||||
};
|
||||
}
|
||||
|
||||
// Always use mailto: intent — this is the most reliable path.
|
||||
// It opens the default email app with To pre-filled, regardless of current screen.
|
||||
console.log(`compose_email: Launching mailto:${emailAddress}`);
|
||||
runAdbCommand([
|
||||
"shell", "am", "start", "-a", "android.intent.action.SENDTO",
|
||||
"-d", `mailto:${emailAddress}`,
|
||||
]);
|
||||
Bun.sleepSync(2500);
|
||||
|
||||
// Re-scan to find the compose screen
|
||||
const freshElements = rescanScreen();
|
||||
const editables = freshElements
|
||||
.filter((el) => el.editable && el.enabled)
|
||||
.sort((a, b) => a.center[1] - b.center[1]);
|
||||
|
||||
if (editables.length === 0) {
|
||||
return { success: false, message: "Launched email compose but no editable fields appeared" };
|
||||
}
|
||||
|
||||
// Find the body field — mailto: already handled the To field
|
||||
let bodyField = findEmailField(editables, BODY_FIELD_PATTERN, BODY_HINT_PATTERN);
|
||||
if (!bodyField) {
|
||||
// Positional fallback: body is the last/largest editable field
|
||||
bodyField = editables[editables.length - 1];
|
||||
}
|
||||
|
||||
const [bx, by] = bodyField.center;
|
||||
console.log(`compose_email: Tapping Body field at (${bx}, ${by})`);
|
||||
runAdbCommand(["shell", "input", "tap", String(bx), String(by)]);
|
||||
Bun.sleepSync(300);
|
||||
|
||||
// Paste body content — use explicit text if provided, otherwise paste clipboard
|
||||
if (bodyContent) {
|
||||
safeClipboardSet(bodyContent);
|
||||
Bun.sleepSync(200);
|
||||
}
|
||||
runAdbCommand(["shell", "input", "keyevent", "279"]); // KEYCODE_PASTE
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Email compose opened to ${emailAddress}, body pasted`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user