feat(agent): add Stage 1 deterministic goal parser
This commit is contained in:
347
server/src/agent/parser.ts
Normal file
347
server/src/agent/parser.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user