348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
/**
|
|
* Stage 1: Deterministic goal parser for DroidClaw pipeline.
|
|
*
|
|
* Converts natural-language goals into Android intents or app launches
|
|
* using regex patterns. Zero LLM calls.
|
|
*
|
|
* Patterns are DATA-DRIVEN: only enabled when the device reports that
|
|
* an app supports the corresponding intent scheme (via installed apps
|
|
* with intent capabilities).
|
|
*/
|
|
|
|
import type {
|
|
InstalledApp,
|
|
PipelineResult,
|
|
DeviceCapabilities,
|
|
} from "@droidclaw/shared";
|
|
|
|
// ─── Capability Extraction ───────────────────────────────────
|
|
|
|
/**
|
|
* Build a DeviceCapabilities object from the device's installed apps.
|
|
* Pre-indexes scheme handlers and supported actions for fast pattern matching.
|
|
*/
|
|
export function buildCapabilities(apps: InstalledApp[]): DeviceCapabilities {
|
|
const schemeHandlers: Record<string, string[]> = {};
|
|
const supportedActions = new Set<string>();
|
|
|
|
for (const app of apps) {
|
|
if (!app.intents) continue;
|
|
for (const intent of app.intents) {
|
|
if (intent.startsWith("VIEW:")) {
|
|
const scheme = intent.slice(5);
|
|
if (!schemeHandlers[scheme]) schemeHandlers[scheme] = [];
|
|
schemeHandlers[scheme].push(app.packageName);
|
|
} else if (intent.startsWith("SENDTO:")) {
|
|
const scheme = intent.slice(7);
|
|
if (!schemeHandlers[scheme]) schemeHandlers[scheme] = [];
|
|
schemeHandlers[scheme].push(app.packageName);
|
|
} else if (intent.startsWith("SEND:")) {
|
|
supportedActions.add(intent);
|
|
} else {
|
|
supportedActions.add(intent);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { apps, schemeHandlers, supportedActions };
|
|
}
|
|
|
|
// ─── App Name Resolution ─────────────────────────────────────
|
|
|
|
function resolveApp(
|
|
name: string,
|
|
apps: InstalledApp[]
|
|
): InstalledApp | undefined {
|
|
const lower = name.toLowerCase();
|
|
const exact = apps.find((a) => a.label.toLowerCase() === lower);
|
|
if (exact) return exact;
|
|
return apps.find((a) => a.label.toLowerCase().includes(lower));
|
|
}
|
|
|
|
// ─── Regex Patterns ──────────────────────────────────────────
|
|
|
|
type PatternMatcher = (
|
|
goal: string,
|
|
caps: DeviceCapabilities
|
|
) => PipelineResult | null;
|
|
|
|
const matchCall: PatternMatcher = (goal, caps) => {
|
|
if (!caps.schemeHandlers["tel"]) return null;
|
|
const m = goal.match(/^(?:call|phone|dial)\s+(.+)$/i);
|
|
if (!m) return null;
|
|
const target = m[1].trim();
|
|
const isNumber = /^[\d\s\+\-\(\)]+$/.test(target);
|
|
if (isNumber) {
|
|
const cleaned = target.replace(/[\s\-\(\)]/g, "");
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.DIAL",
|
|
uri: `tel:${cleaned}`,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.DIAL",
|
|
uri: `tel:${encodeURIComponent(target)}`,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchSms: PatternMatcher = (goal, caps) => {
|
|
if (!caps.schemeHandlers["sms"] && !caps.schemeHandlers["smsto"])
|
|
return null;
|
|
const m = goal.match(
|
|
/^(?:text|sms|send\s+(?:a\s+)?(?:text|sms|message)\s+to)\s+([\d\+\-\s\(\)]+?)(?:\s+(?:saying|with|message|that says)\s+(.+))?$/i
|
|
);
|
|
if (!m) return null;
|
|
const number = m[1].replace(/[\s\-\(\)]/g, "");
|
|
const body = m[2]?.trim();
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.SENDTO",
|
|
uri: `smsto:${number}`,
|
|
extras: body ? { sms_body: body } : undefined,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchWhatsApp: PatternMatcher = (goal, caps) => {
|
|
const hasWa =
|
|
caps.schemeHandlers["whatsapp"] ||
|
|
caps.apps.some((a) => a.packageName === "com.whatsapp");
|
|
if (!hasWa) return null;
|
|
const m = goal.match(
|
|
/^(?:whatsapp|send\s+(?:a\s+)?whatsapp\s+(?:message\s+)?to)\s+([\d\+\-\s\(\)]+?)(?:\s+(?:saying|with|message|that says)\s+(.+))?$/i
|
|
);
|
|
if (!m) return null;
|
|
const number = m[1].replace(/[\s\-\(\)]/g, "");
|
|
const text = m[2]?.trim() ?? "";
|
|
const encoded = text ? `?text=${encodeURIComponent(text)}` : "";
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.VIEW",
|
|
uri: `https://wa.me/${number}${encoded}`,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchNavigation: PatternMatcher = (goal, caps) => {
|
|
if (!caps.schemeHandlers["google.navigation"]) return null;
|
|
const m = goal.match(
|
|
/^(?:navigate|drive|directions?|take me)\s+to\s+(.+)$/i
|
|
);
|
|
if (!m) return null;
|
|
const place = m[1].trim();
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.VIEW",
|
|
uri: `google.navigation:q=${encodeURIComponent(place)}&mode=d`,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchMapSearch: PatternMatcher = (goal, caps) => {
|
|
if (!caps.schemeHandlers["geo"]) return null;
|
|
const m = goal.match(
|
|
/^(?:find|search|look for|show me)\s+(.+?)(?:\s+(?:nearby|near me|on maps|around here))$/i
|
|
);
|
|
if (!m) return null;
|
|
const query = m[1].trim();
|
|
if (query.length > 50) return null;
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.VIEW",
|
|
uri: `geo:0,0?q=${encodeURIComponent(query)}`,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchAlarm: PatternMatcher = (goal, caps) => {
|
|
if (!caps.supportedActions.has("android.intent.action.SET_ALARM"))
|
|
return null;
|
|
const m = goal.match(
|
|
/^set\s+(?:an?\s+)?alarm\s+(?:for|at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s+(?:called|named|labeled|saying)\s+(.+))?$/i
|
|
);
|
|
if (!m) return null;
|
|
let hour = parseInt(m[1], 10);
|
|
const minutes = m[2] ? parseInt(m[2], 10) : 0;
|
|
const ampm = m[3]?.toLowerCase();
|
|
const label = m[4]?.trim();
|
|
|
|
if (ampm === "pm" && hour < 12) hour += 12;
|
|
if (ampm === "am" && hour === 12) hour = 0;
|
|
|
|
const extras: Record<string, string> = {
|
|
"android.intent.extra.alarm.HOUR": String(hour),
|
|
"android.intent.extra.alarm.MINUTES": String(minutes),
|
|
};
|
|
if (label) extras["android.intent.extra.alarm.MESSAGE"] = label;
|
|
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.SET_ALARM",
|
|
extras,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchTimer: PatternMatcher = (goal, caps) => {
|
|
if (!caps.supportedActions.has("android.intent.action.SET_TIMER"))
|
|
return null;
|
|
const m = goal.match(
|
|
/^set\s+(?:a\s+)?timer\s+(?:for\s+)?(\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?)(?:\s+(?:called|named|labeled|saying)\s+(.+))?$/i
|
|
);
|
|
if (!m) return null;
|
|
let seconds = parseInt(m[1], 10);
|
|
const unit = m[2].toLowerCase();
|
|
if (unit.startsWith("min")) seconds *= 60;
|
|
if (unit.startsWith("hour") || unit.startsWith("hr")) seconds *= 3600;
|
|
const label = m[3]?.trim();
|
|
|
|
const extras: Record<string, string> = {
|
|
"android.intent.extra.alarm.LENGTH": String(seconds),
|
|
};
|
|
if (label) extras["android.intent.extra.alarm.MESSAGE"] = label;
|
|
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.SET_TIMER",
|
|
extras,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchEmail: PatternMatcher = (goal, caps) => {
|
|
if (!caps.schemeHandlers["mailto"]) return null;
|
|
const m = goal.match(
|
|
/^(?:email|mail|send\s+(?:an?\s+)?email\s+to)\s+([\w.\-+]+@[\w.\-]+)(?:\s+(?:about|subject|with subject|saying)\s+(.+))?$/i
|
|
);
|
|
if (!m) return null;
|
|
const email = m[1];
|
|
const subject = m[2]?.trim();
|
|
return {
|
|
stage: "parser",
|
|
type: "intent",
|
|
intent: {
|
|
intentAction: "android.intent.action.SENDTO",
|
|
uri: `mailto:${email}`,
|
|
extras: subject
|
|
? { "android.intent.extra.SUBJECT": subject }
|
|
: undefined,
|
|
},
|
|
};
|
|
};
|
|
|
|
const matchOpenApp: PatternMatcher = (goal, caps) => {
|
|
const m = goal.match(
|
|
/^(?:open|launch|start|go to)\s+(?:the\s+)?(.+?)(?:\s+app)?$/i
|
|
);
|
|
if (!m) return null;
|
|
const name = m[1].trim();
|
|
|
|
if (/^https?:\/\//i.test(name)) {
|
|
return { stage: "parser", type: "open_url", url: name };
|
|
}
|
|
|
|
const app = resolveApp(name, caps.apps);
|
|
if (app) {
|
|
return { stage: "parser", type: "launch", packageName: app.packageName };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const matchSettings: PatternMatcher = (goal, _caps) => {
|
|
const settingKeywords: Record<string, string> = {
|
|
wifi: "wifi",
|
|
"wi-fi": "wifi",
|
|
bluetooth: "bluetooth",
|
|
display: "display",
|
|
brightness: "display",
|
|
sound: "sound",
|
|
volume: "sound",
|
|
battery: "battery",
|
|
location: "location",
|
|
gps: "location",
|
|
apps: "apps",
|
|
applications: "apps",
|
|
date: "date",
|
|
time: "date",
|
|
accessibility: "accessibility",
|
|
developer: "developer",
|
|
"do not disturb": "dnd",
|
|
dnd: "dnd",
|
|
network: "network",
|
|
storage: "storage",
|
|
security: "security",
|
|
};
|
|
|
|
const m = goal.match(/^(?:open\s+)?(.+?)\s+settings$/i);
|
|
if (!m) return null;
|
|
const key = m[1].trim().toLowerCase();
|
|
const setting = settingKeywords[key];
|
|
if (setting) {
|
|
return { stage: "parser", type: "open_settings", setting };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const matchOpenUrl: PatternMatcher = (goal, _caps) => {
|
|
const m = goal.match(
|
|
/^(?:open|go to|visit|navigate to)\s+(https?:\/\/\S+)$/i
|
|
);
|
|
if (!m) return null;
|
|
return { stage: "parser", type: "open_url", url: m[1] };
|
|
};
|
|
|
|
// ─── Pattern Registry ────────────────────────────────────────
|
|
|
|
const PATTERNS: PatternMatcher[] = [
|
|
matchOpenUrl,
|
|
matchCall,
|
|
matchSms,
|
|
matchWhatsApp,
|
|
matchEmail,
|
|
matchNavigation,
|
|
matchMapSearch,
|
|
matchAlarm,
|
|
matchTimer,
|
|
matchSettings,
|
|
matchOpenApp,
|
|
];
|
|
|
|
// ─── Public API ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Stage 1: Parse a goal deterministically.
|
|
* Returns a PipelineResult if a pattern matches, or { type: "passthrough" }
|
|
* if no pattern matches and the goal should proceed to Stage 2.
|
|
*/
|
|
export function parseGoal(
|
|
goal: string,
|
|
caps: DeviceCapabilities
|
|
): PipelineResult {
|
|
const trimmed = goal.trim();
|
|
for (const matcher of PATTERNS) {
|
|
const result = matcher(trimmed, caps);
|
|
if (result) return result;
|
|
}
|
|
return { stage: "parser", type: "passthrough" };
|
|
}
|