refactor(agent): delete preprocessor.ts (replaced by parser.ts)

This commit is contained in:
Sanju Sivalingam
2026-02-18 00:28:50 +05:30
parent d5c3466554
commit 3769b21ed1
6 changed files with 190 additions and 232 deletions

View File

@@ -6,6 +6,9 @@ import android.content.Intent
import android.graphics.Path import android.graphics.Path
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.ContactsContract
import android.provider.CalendarContract
import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityNodeInfo
import com.thisux.droidclaw.model.ServerMessage import com.thisux.droidclaw.model.ServerMessage
@@ -35,16 +38,17 @@ class GestureExecutor(private val service: DroidClawAccessibilityService) {
msg.x2 ?: 0, msg.y2 ?: 0, msg.x2 ?: 0, msg.y2 ?: 0,
msg.duration ?: 300 msg.duration ?: 300
) )
"launch" -> executeLaunch(msg.packageName ?: "") "launch" -> executeLaunch(msg)
"clear" -> executeClear() "clear" -> executeClear()
"clipboard_set" -> executeClipboardSet(msg.text ?: "") "clipboard_set" -> executeClipboardSet(msg.text ?: "")
"clipboard_get" -> executeClipboardGet() "clipboard_get" -> executeClipboardGet()
"paste" -> executePaste() "paste" -> executePaste()
"open_url" -> executeOpenUrl(msg.url ?: "") "open_url" -> executeOpenUrl(msg.url ?: "")
"switch_app" -> executeLaunch(msg.packageName ?: "") "switch_app" -> executeLaunch(msg)
"keyevent" -> executeKeyEvent(msg.code ?: 0) "keyevent" -> executeKeyEvent(msg.code ?: 0)
"open_settings" -> executeOpenSettings() "open_settings" -> executeOpenSettings(msg.setting)
"wait" -> executeWait(msg.duration ?: 1000) "wait" -> executeWait(msg.duration ?: 1000)
"intent" -> executeIntent(msg)
else -> ActionResult(false, "Unknown action: ${msg.type}") else -> ActionResult(false, "Unknown action: ${msg.type}")
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -125,10 +129,32 @@ class GestureExecutor(private val service: DroidClawAccessibilityService) {
return dispatchSwipeGesture(x1, y1, x2, y2, duration) return dispatchSwipeGesture(x1, y1, x2, y2, duration)
} }
private fun executeLaunch(packageName: String): ActionResult { private fun executeLaunch(msg: ServerMessage): ActionResult {
val packageName = msg.packageName ?: ""
val uri = msg.intentUri
val extras = msg.intentExtras
// If URI is provided, use ACTION_VIEW intent (deep link / intent with data)
if (!uri.isNullOrEmpty()) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (packageName.isNotEmpty()) setPackage(packageName)
extras?.forEach { (k, v) -> putExtra(k, v) }
}
return try {
service.startActivity(intent)
ActionResult(true)
} catch (e: Exception) {
ActionResult(false, "Intent failed: ${e.message}")
}
}
// Standard package launch
if (packageName.isEmpty()) return ActionResult(false, "No package or URI provided")
val intent = service.packageManager.getLaunchIntentForPackage(packageName) val intent = service.packageManager.getLaunchIntentForPackage(packageName)
?: return ActionResult(false, "Package not found: $packageName") ?: return ActionResult(false, "Package not found: $packageName")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
extras?.forEach { (k, v) -> intent.putExtra(k, v) }
service.startActivity(intent) service.startActivity(intent)
return ActionResult(true) return ActionResult(true)
} }
@@ -194,12 +220,71 @@ class GestureExecutor(private val service: DroidClawAccessibilityService) {
} }
} }
private fun executeOpenSettings(): ActionResult { private fun executeIntent(msg: ServerMessage): ActionResult {
val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply { val intentAction = msg.intentAction
?: return ActionResult(false, "No intentAction provided")
val intent = Intent(intentAction).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val uri = msg.intentUri?.let { Uri.parse(it) }
val mimeType = msg.intentType
when {
uri != null && mimeType != null -> setDataAndType(uri, mimeType)
uri != null -> data = uri
mimeType != null -> type = mimeType
}
msg.packageName?.let { setPackage(it) }
// Auto-detect numeric extras (needed for SET_ALARM HOUR/MINUTES etc.)
msg.intentExtras?.forEach { (k, v) ->
val intVal = v.toIntOrNull()
val longVal = v.toLongOrNull()
when {
intVal != null -> putExtra(k, intVal)
longVal != null -> putExtra(k, longVal)
else -> putExtra(k, v)
}
}
}
return try {
service.startActivity(intent)
ActionResult(true)
} catch (e: Exception) {
ActionResult(false, "Intent failed: ${e.message}")
}
}
private fun executeOpenSettings(setting: String?): ActionResult {
val action = when (setting) {
"wifi" -> Settings.ACTION_WIFI_SETTINGS
"bluetooth" -> Settings.ACTION_BLUETOOTH_SETTINGS
"display" -> Settings.ACTION_DISPLAY_SETTINGS
"sound" -> Settings.ACTION_SOUND_SETTINGS
"battery" -> Intent.ACTION_POWER_USAGE_SUMMARY
"location" -> Settings.ACTION_LOCATION_SOURCE_SETTINGS
"apps" -> Settings.ACTION_APPLICATION_SETTINGS
"date" -> Settings.ACTION_DATE_SETTINGS
"accessibility" -> Settings.ACTION_ACCESSIBILITY_SETTINGS
"developer" -> Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS
"dnd" -> "android.settings.ZEN_MODE_SETTINGS"
"network" -> Settings.ACTION_WIRELESS_SETTINGS
"storage" -> Settings.ACTION_INTERNAL_STORAGE_SETTINGS
"security" -> Settings.ACTION_SECURITY_SETTINGS
else -> Settings.ACTION_SETTINGS
}
val intent = Intent(action).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
return try {
service.startActivity(intent) service.startActivity(intent)
return ActionResult(true) ActionResult(true)
} catch (e: Exception) {
ActionResult(false, "Settings intent failed: ${e.message}")
}
} }
private suspend fun executeWait(duration: Int): ActionResult { private suspend fun executeWait(duration: Int): ActionResult {

View File

@@ -44,7 +44,7 @@ class CommandRouter(
"tap", "type", "enter", "back", "home", "notifications", "tap", "type", "enter", "back", "home", "notifications",
"longpress", "swipe", "launch", "clear", "clipboard_set", "longpress", "swipe", "launch", "clear", "clipboard_set",
"clipboard_get", "paste", "open_url", "switch_app", "clipboard_get", "paste", "open_url", "switch_app",
"keyevent", "open_settings", "wait" -> handleAction(msg) "keyevent", "open_settings", "wait", "intent" -> handleAction(msg)
"goal_started" -> { "goal_started" -> {
currentSessionId.value = msg.sessionId currentSessionId.value = msg.sessionId

View File

@@ -26,6 +26,7 @@ import com.thisux.droidclaw.model.AppsMessage
import com.thisux.droidclaw.model.InstalledAppInfo import com.thisux.droidclaw.model.InstalledAppInfo
import com.thisux.droidclaw.util.DeviceInfoHelper import com.thisux.droidclaw.util.DeviceInfoHelper
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -192,11 +193,93 @@ class ConnectionService : LifecycleService() {
val pm = packageManager val pm = packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val activities = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL) val activities = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL)
return activities.mapNotNull { resolveInfo -> val apps = activities.mapNotNull { resolveInfo ->
val pkg = resolveInfo.activityInfo.packageName val pkg = resolveInfo.activityInfo.packageName
val label = resolveInfo.loadLabel(pm).toString() val label = resolveInfo.loadLabel(pm).toString()
InstalledAppInfo(packageName = pkg, label = label) InstalledAppInfo(packageName = pkg, label = label)
}.distinctBy { it.packageName }.sortedBy { it.label.lowercase() } }.distinctBy { it.packageName }.sortedBy { it.label.lowercase() }
// Discover intent capabilities per app
val intentMap = discoverIntentCapabilities()
return apps.map { app ->
val intents = intentMap[app.packageName]
if (intents != null) app.copy(intents = intents.toList()) else app
}
}
/**
* Probe installed apps to discover which URI schemes and intent actions
* each app supports. Returns a map of packageName -> list of capabilities.
* Format: "VIEW:scheme", "SENDTO:scheme", "SEND:mime", or action name.
*/
private fun discoverIntentCapabilities(): Map<String, List<String>> {
val pm = packageManager
val result = mutableMapOf<String, MutableSet<String>>()
// Probe ACTION_VIEW with common URI schemes
val viewSchemes = listOf(
"tel", "sms", "smsto", "mailto", "geo", "https", "http",
"whatsapp", "instagram", "twitter", "fb", "spotify",
"vnd.youtube", "zoomus", "upi", "phonepe", "paytm",
"gpay", "tez", "google.navigation", "uber", "skype",
"viber", "telegram", "snapchat", "linkedin", "reddit",
"swiggy", "zomato", "ola", "maps.google.com"
)
for (scheme in viewSchemes) {
try {
val probe = Intent(Intent.ACTION_VIEW, Uri.parse("$scheme://test"))
val resolvers = pm.queryIntentActivities(probe, PackageManager.MATCH_DEFAULT_ONLY)
for (info in resolvers) {
result.getOrPut(info.activityInfo.packageName) { mutableSetOf() }
.add("VIEW:$scheme")
}
} catch (_: Exception) { /* skip invalid scheme */ }
}
// Probe ACTION_SENDTO (sms, mailto)
for (scheme in listOf("sms", "mailto")) {
try {
val probe = Intent(Intent.ACTION_SENDTO, Uri.parse("$scheme:test"))
val resolvers = pm.queryIntentActivities(probe, PackageManager.MATCH_DEFAULT_ONLY)
for (info in resolvers) {
result.getOrPut(info.activityInfo.packageName) { mutableSetOf() }
.add("SENDTO:$scheme")
}
} catch (_: Exception) {}
}
// Probe ACTION_SEND (share) with common MIME types
for (mime in listOf("text/plain", "image/*")) {
try {
val probe = Intent(Intent.ACTION_SEND).apply { type = mime }
val resolvers = pm.queryIntentActivities(probe, PackageManager.MATCH_DEFAULT_ONLY)
for (info in resolvers) {
result.getOrPut(info.activityInfo.packageName) { mutableSetOf() }
.add("SEND:$mime")
}
} catch (_: Exception) {}
}
// Probe special actions
val specialActions = listOf(
"android.intent.action.SET_ALARM",
"android.intent.action.SET_TIMER",
"android.intent.action.DIAL",
"android.intent.action.INSERT",
"android.intent.action.CALL"
)
for (action in specialActions) {
try {
val probe = Intent(action)
val resolvers = pm.queryIntentActivities(probe, PackageManager.MATCH_DEFAULT_ONLY)
for (info in resolvers) {
result.getOrPut(info.activityInfo.packageName) { mutableSetOf() }
.add(action)
}
} catch (_: Exception) {}
}
return result.mapValues { it.value.toList() }
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {

View File

@@ -61,7 +61,8 @@ data class HeartbeatMessage(
@Serializable @Serializable
data class InstalledAppInfo( data class InstalledAppInfo(
val packageName: String, val packageName: String,
val label: String val label: String,
val intents: List<String> = emptyList()
) )
@Serializable @Serializable
@@ -94,5 +95,11 @@ data class ServerMessage(
val text: String? = null, val text: String? = null,
val packageName: String? = null, val packageName: String? = null,
val url: String? = null, val url: String? = null,
val code: Int? = null val code: Int? = null,
// Intent fields
val intentAction: String? = null,
val intentUri: String? = null,
val intentType: String? = null,
val intentExtras: Map<String, String>? = null,
val setting: String? = null
) )

View File

@@ -20,7 +20,7 @@ export type ServerToDeviceMessage =
| { type: "back"; requestId: string } | { type: "back"; requestId: string }
| { type: "home"; requestId: string } | { type: "home"; requestId: string }
| { type: "longpress"; requestId: string; x: number; y: number } | { type: "longpress"; requestId: string; x: number; y: number }
| { type: "launch"; requestId: string; packageName: string } | { type: "launch"; requestId: string; packageName: string; intentUri?: string; intentExtras?: Record<string, string> }
| { type: "clear"; requestId: string } | { type: "clear"; requestId: string }
| { type: "clipboard_set"; requestId: string; text: string } | { type: "clipboard_set"; requestId: string; text: string }
| { type: "clipboard_get"; requestId: string } | { type: "clipboard_get"; requestId: string }
@@ -29,8 +29,9 @@ export type ServerToDeviceMessage =
| { type: "switch_app"; requestId: string; packageName: string } | { type: "switch_app"; requestId: string; packageName: string }
| { type: "notifications"; requestId: string } | { type: "notifications"; requestId: string }
| { type: "keyevent"; requestId: string; code: number } | { type: "keyevent"; requestId: string; code: number }
| { type: "open_settings"; requestId: string } | { type: "open_settings"; requestId: string; setting?: string }
| { type: "wait"; requestId: string; duration?: number } | { type: "wait"; requestId: string; duration?: number }
| { type: "intent"; requestId: string; intentAction: string; intentUri?: string; intentType?: string; intentExtras?: Record<string, string>; packageName?: string }
| { type: "ping" } | { type: "ping" }
| { type: "goal_started"; sessionId: string; goal: string } | { type: "goal_started"; sessionId: string; goal: string }
| { type: "goal_completed"; sessionId: string; success: boolean; stepsUsed: number }; | { type: "goal_completed"; sessionId: string; success: boolean; stepsUsed: number };

View File

@@ -1,218 +0,0 @@
/**
* Goal preprocessor for DroidClaw agent loop.
*
* Intercepts simple goals (like "open youtube") and executes direct
* actions before the LLM loop starts. This avoids wasting 20 steps
* on what should be a 2-step task, especially with weaker LLMs that
* navigate via UI instead of using programmatic launch commands.
*/
import { sessions } from "../ws/sessions.js";
import { db } from "../db.js";
import { device as deviceTable } from "../schema.js";
import { eq } from "drizzle-orm";
// ─── Installed App type ──────────────────────────────────────
interface InstalledApp {
packageName: string;
label: string;
}
// ─── Fallback App Name → Package Name Map ────────────────────
// Used only when device has no installed apps data in DB.
const FALLBACK_PACKAGES: Record<string, string> = {
youtube: "com.google.android.youtube",
gmail: "com.google.android.gm",
chrome: "com.android.chrome",
maps: "com.google.android.apps.maps",
photos: "com.google.android.apps.photos",
drive: "com.google.android.apps.docs",
calendar: "com.google.android.calendar",
contacts: "com.google.android.contacts",
messages: "com.google.android.apps.messaging",
phone: "com.google.android.dialer",
clock: "com.google.android.deskclock",
calculator: "com.google.android.calculator",
camera: "com.android.camera",
settings: "com.android.settings",
files: "com.google.android.apps.nbu.files",
play: "com.android.vending",
"play store": "com.android.vending",
"google play": "com.android.vending",
whatsapp: "com.whatsapp",
telegram: "org.telegram.messenger",
instagram: "com.instagram.android",
facebook: "com.facebook.katana",
twitter: "com.twitter.android",
x: "com.twitter.android",
spotify: "com.spotify.music",
netflix: "com.netflix.mediaclient",
tiktok: "com.zhiliaoapp.musically",
snapchat: "com.snapchat.android",
reddit: "com.reddit.frontpage",
discord: "com.discord",
slack: "com.Slack",
zoom: "us.zoom.videomeetings",
teams: "com.microsoft.teams",
outlook: "com.microsoft.office.outlook",
"google meet": "com.google.android.apps.tachyon",
meet: "com.google.android.apps.tachyon",
keep: "com.google.android.keep",
notes: "com.google.android.keep",
sheets: "com.google.android.apps.docs.editors.sheets",
docs: "com.google.android.apps.docs.editors.docs",
slides: "com.google.android.apps.docs.editors.slides",
translate: "com.google.android.apps.translate",
weather: "com.google.android.apps.weather",
news: "com.google.android.apps.magazines",
podcasts: "com.google.android.apps.podcasts",
fitbit: "com.fitbit.FitbitMobile",
uber: "com.ubercab",
lyft: "me.lyft.android",
amazon: "com.amazon.mShop.android.shopping",
ebay: "com.ebay.mobile",
linkedin: "com.linkedin.android",
pinterest: "com.pinterest",
twitch: "tv.twitch.android.app",
};
// ─── Goal Pattern Matching ───────────────────────────────────
interface PreprocessResult {
/** Whether the preprocessor handled the goal */
handled: boolean;
/** Command sent to device (if any) */
command?: Record<string, unknown>;
/** Updated goal text for the LLM (optional) */
refinedGoal?: string;
}
/**
* Build a label→packageName lookup from the device's installed apps.
* Keys are lowercase app labels (e.g. "youtube", "play store").
*/
function buildInstalledAppMap(apps: InstalledApp[]): Record<string, string> {
const map: Record<string, string> = {};
for (const app of apps) {
map[app.label.toLowerCase()] = app.packageName;
}
return map;
}
/**
* Try to find an app name at the start of a goal string.
* Checks device's installed apps first, then falls back to hardcoded map.
* Returns the package name and remaining text, or null.
*/
function matchAppName(
lower: string,
installedApps: InstalledApp[]
): { pkg: string; appName: string; rest: string } | null {
// Build combined lookup: installed apps take priority over fallback
const installedMap = buildInstalledAppMap(installedApps);
const combined: Record<string, string> = { ...FALLBACK_PACKAGES, ...installedMap };
// Try longest app names first (e.g. "google meet" before "meet")
const sorted = Object.keys(combined).sort((a, b) => b.length - a.length);
for (const name of sorted) {
// Match: "open <app> [app] and <rest>" or "open <app> [app]"
const pattern = new RegExp(
`^(?:open|launch|start|go to)\\s+(?:the\\s+)?${escapeRegex(name)}(?:\\s+app)?(?:\\s+(?:and|then)\\s+(.+))?$`
);
const m = lower.match(pattern);
if (m) {
return { pkg: combined[name], appName: name, rest: m[1]?.trim() ?? "" };
}
}
return null;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Fetch installed apps from the device record in DB.
*/
async function fetchInstalledApps(persistentDeviceId: string): Promise<InstalledApp[]> {
try {
const rows = await db
.select({ info: deviceTable.deviceInfo })
.from(deviceTable)
.where(eq(deviceTable.id, persistentDeviceId))
.limit(1);
const info = rows[0]?.info as Record<string, unknown> | null;
return (info?.installedApps as InstalledApp[]) ?? [];
} catch {
return [];
}
}
/**
* Attempt to preprocess a goal before the LLM loop.
*
* Three outcomes:
* 1. { handled: true, refinedGoal: undefined } — goal fully handled (pure "open X")
* 2. { handled: true, refinedGoal: "..." } — app launched, LLM continues with refined goal
* 3. { handled: false } — preprocessor can't help, LLM gets full goal
*/
export async function preprocessGoal(
deviceId: string,
goal: string,
persistentDeviceId?: string
): Promise<PreprocessResult> {
const lower = goal.toLowerCase().trim();
// Fetch device's actual installed apps for accurate package resolution
const installedApps = persistentDeviceId ? await fetchInstalledApps(persistentDeviceId) : [];
// ── Pattern: "open <app> [and <remaining>]" ───────────────
const appMatch = matchAppName(lower, installedApps);
if (appMatch) {
try {
await sessions.sendCommand(deviceId, {
type: "launch",
packageName: appMatch.pkg,
});
if (appMatch.rest) {
// Compound goal: app launched, pass remaining instructions to LLM
console.log(`[Preprocessor] Launched ${appMatch.pkg}, refined goal: ${appMatch.rest}`);
return {
handled: true,
command: { type: "launch", packageName: appMatch.pkg },
refinedGoal: appMatch.rest,
};
}
// Pure "open X" — fully handled
console.log(`[Preprocessor] Launched ${appMatch.pkg} for goal: ${goal}`);
return { handled: true, command: { type: "launch", packageName: appMatch.pkg } };
} catch (err) {
console.warn(`[Preprocessor] Failed to launch ${appMatch.pkg}: ${err}`);
// Fall through to LLM
}
}
// ── Pattern: "open <url>" or "go to <url>" ────────────────
const urlMatch = lower.match(
/^(?:open|go to|visit|navigate to)\s+(https?:\/\/\S+)$/
);
if (urlMatch) {
const url = urlMatch[1];
try {
await sessions.sendCommand(deviceId, { type: "open_url", url });
console.log(`[Preprocessor] Opened URL: ${url}`);
return { handled: true, command: { type: "open_url", url } };
} catch (err) {
console.warn(`[Preprocessor] Failed to open URL: ${err}`);
}
}
return { handled: false };
}