|
| 1 | +import type { ComposioTool } from "@cheatcode/composio"; |
| 2 | +import type { ToolkitAction } from "@cheatcode/types/api"; |
| 3 | + |
| 4 | +const DESTRUCTIVE_ACTION_PATTERN = |
| 5 | + /\b(delete|destroy|disconnect|empty|erase|purge|remove|revoke|uninstall)\b/i; |
| 6 | +const READ_ACTION_PATTERN = |
| 7 | + /^(check|download|export|find|inspect|list|look up|read|retrieve|search|show|view)\b/i; |
| 8 | +const DRAFT_ACTION_PATTERN = /^(compose|create|write)\b.*\bdraft\b/i; |
| 9 | +const SEND_EXISTING_ACTION_PATTERN = /^(publish|send)\b.*\b(draft|post)\b/i; |
| 10 | +const MESSAGE_ACTION_PATTERN = /^(forward|post|reply|send)\b/i; |
| 11 | +const CHANGE_ACTION_PATTERN = |
| 12 | + /^(add|approve|archive|assign|cancel|close|connect|create|disable|edit|enable|invite|log|mark|merge|move|publish|record|reject|restore|schedule|set|start|stop|update|upload)\b/i; |
| 13 | +const ARTICLE_ACTION_PATTERN = |
| 14 | + /^(add|create|delete|edit|find|forward|move|open|post|publish|remove|reply to|restore|search|send|update|upload|view)\s+(.+)$/i; |
| 15 | +const DETERMINER_PATTERN = /^(a|all|an|any|every|my|one|some|the|these|this|those|your)\b/i; |
| 16 | +const PREPOSITION_PATTERN = /^(and|by|for|from|in|inside|on|or|to|with)\b/i; |
| 17 | +const UNCOUNTABLE_NOUNS = new Set(["access", "content", "data", "information", "mail"]); |
| 18 | +const IRREGULAR_PLURAL_NOUNS = new Set(["children", "feet", "men", "people", "teeth", "women"]); |
| 19 | +const SINGULAR_NOUNS_ENDING_IN_S = new Set([ |
| 20 | + "alias", |
| 21 | + "analysis", |
| 22 | + "basis", |
| 23 | + "crisis", |
| 24 | + "source", |
| 25 | + "status", |
| 26 | +]); |
| 27 | +const CONSONANT_SOUND_VOWEL_WORDS = |
| 28 | + /^(ewe|euro|one|uni(?:corn|form|que|t|vers)|user|utility|u[rs]l)\b/i; |
| 29 | + |
| 30 | +export function presentIntegrationAction( |
| 31 | + tool: ComposioTool, |
| 32 | + toolkitDisplayName?: string, |
| 33 | +): ToolkitAction { |
| 34 | + const fallbackName = actionNameFromSlug(tool.slug); |
| 35 | + const toolkitName = toolkitDisplayName ?? tool.toolkit?.name; |
| 36 | + const name = humanizeActionName(tool.name ?? fallbackName, toolkitName) || fallbackName; |
| 37 | + return { |
| 38 | + name, |
| 39 | + prompt: actionPrompt(name, tool), |
| 40 | + slug: tool.slug, |
| 41 | + }; |
| 42 | +} |
| 43 | + |
| 44 | +function humanizeActionName(value: string, toolkitName: string | undefined): string { |
| 45 | + const cleaned = value |
| 46 | + .trim() |
| 47 | + .replace(/\s*\([^)]*\)\s*$/u, "") |
| 48 | + .replace(/\bfrom natural language\b/giu, "") |
| 49 | + .replace(/\bauth(?:enticated)? user\b/giu, "your account") |
| 50 | + .replace(/\s+by\s+user IDs?\b/giu, " for an account") |
| 51 | + .replace(/\s+(?:by|using|with)\s+(?:its\s+)?(?:[A-Za-z]+\s+){0,2}IDs?\b/giu, "") |
| 52 | + .replace(/\buser IDs?\b/giu, "account") |
| 53 | + .replace(/\bCRM object\b/giu, "CRM record") |
| 54 | + .replace(/^get about user$/iu, "View user profile") |
| 55 | + .replace(/^get about me$/iu, "View my profile") |
| 56 | + .replace(/^trash\s+(.+)$/iu, "Move $1 to trash") |
| 57 | + .replace(/^move to trash$/iu, "Move an item to trash") |
| 58 | + .replace(/^untrash\s+(.+)$/iu, "Restore $1 from trash") |
| 59 | + .replace(/^insert row database\b/iu, "Add database row") |
| 60 | + .replace(/^insert\b/iu, "Add") |
| 61 | + .replace(/^patch\b/iu, "Update") |
| 62 | + .replace(/^query\b/iu, "Search") |
| 63 | + .replace(/^replace\b/iu, "Update") |
| 64 | + .replace(/^batch modify\b/iu, "Update multiple") |
| 65 | + .replace(/^(fetch|get|list|retrieve)\b/iu, "View") |
| 66 | + .replace(/^real-time search\b/iu, "Search") |
| 67 | + .replace(/\bsend-as alias\b/giu, "email alias") |
| 68 | + .replace(/\bpage markdown\b/giu, "page content") |
| 69 | + .replace(/\bview query results\b/giu, "filtered results") |
| 70 | + .replace(/\bview query\b/giu, "filtered view") |
| 71 | + .replace(/\b(?:([A-Za-z]+)\s+)?block children\b/giu, "content inside $1 block") |
| 72 | + .replace(/\bfile upload\b/giu, "uploaded file") |
| 73 | + .replace(/\bchanges start page token\b/giu, "change tracking token") |
| 74 | + .replace(/\bgoogle about this result\b/giu, "details about this result") |
| 75 | + .replace(/\bwith filter\b/giu, "with filters") |
| 76 | + .replace(/\s+/gu, " ") |
| 77 | + .trim(); |
| 78 | + return sentenceCaseActionName(cleaned, toolkitName); |
| 79 | +} |
| 80 | + |
| 81 | +function sentenceCaseActionName(value: string, toolkitName: string | undefined): string { |
| 82 | + const brandWords = new Map( |
| 83 | + toolkitName?.split(/\s+/u).map((word) => [word.toLocaleLowerCase(), word]) ?? [], |
| 84 | + ); |
| 85 | + return value |
| 86 | + .split(" ") |
| 87 | + .map((word, index) => { |
| 88 | + const brandedWord = brandWords.get(word.toLocaleLowerCase()); |
| 89 | + if (brandedWord) { |
| 90 | + return brandedWord; |
| 91 | + } |
| 92 | + if (index === 0) { |
| 93 | + return word; |
| 94 | + } |
| 95 | + return /^[A-Z][a-z]+$/u.test(word) ? word.toLocaleLowerCase() : word; |
| 96 | + }) |
| 97 | + .join(" "); |
| 98 | +} |
| 99 | + |
| 100 | +function actionNameFromSlug(slug: string): string { |
| 101 | + const words = slug.split("_").slice(1).join(" ").toLocaleLowerCase(); |
| 102 | + return words ? words.charAt(0).toLocaleUpperCase() + words.slice(1) : "Use this action"; |
| 103 | +} |
| 104 | + |
| 105 | +function actionPrompt(name: string, tool: ComposioTool): string { |
| 106 | + const goal = naturalActionGoal(lowerFirst(name)); |
| 107 | + if (isDestructiveAction(name, tool)) { |
| 108 | + return `Help me ${goal}. Find the right item and ask for confirmation before making permanent changes.`; |
| 109 | + } |
| 110 | + if (DRAFT_ACTION_PATTERN.test(name)) { |
| 111 | + return `Help me ${goal}. Ask who it is for, the subject, and what it should say.`; |
| 112 | + } |
| 113 | + if (SEND_EXISTING_ACTION_PATTERN.test(name)) { |
| 114 | + return `Help me ${goal}. Find the right one and show it to me before sending.`; |
| 115 | + } |
| 116 | + if (MESSAGE_ACTION_PATTERN.test(name)) { |
| 117 | + return `Help me ${goal}. Ask for the recipient and content, then show me the final version before sending.`; |
| 118 | + } |
| 119 | + if (READ_ACTION_PATTERN.test(name)) { |
| 120 | + return `Help me ${goal}. Ask what I am looking for if needed.`; |
| 121 | + } |
| 122 | + if (CHANGE_ACTION_PATTERN.test(name)) { |
| 123 | + return `Help me ${goal}. Ask for the details you need, then show me what will change before doing it.`; |
| 124 | + } |
| 125 | + return `Help me ${goal}. Ask for any details you need in plain language.`; |
| 126 | +} |
| 127 | + |
| 128 | +function isDestructiveAction(name: string, tool: ComposioTool): boolean { |
| 129 | + if (DESTRUCTIVE_ACTION_PATTERN.test(`${name} ${tool.slug.replaceAll("_", " ")}`)) { |
| 130 | + return true; |
| 131 | + } |
| 132 | + return /\bpermanently\b/i.test(`${tool.humanDescription ?? ""} ${tool.description ?? ""}`); |
| 133 | +} |
| 134 | + |
| 135 | +function lowerFirst(value: string): string { |
| 136 | + return value.charAt(0).toLocaleLowerCase() + value.slice(1); |
| 137 | +} |
| 138 | + |
| 139 | +function naturalActionGoal(value: string): string { |
| 140 | + const match = ARTICLE_ACTION_PATTERN.exec(value); |
| 141 | + if ( |
| 142 | + !match?.[1] || |
| 143 | + !match[2] || |
| 144 | + DETERMINER_PATTERN.test(match[2]) || |
| 145 | + PREPOSITION_PATTERN.test(match[2]) |
| 146 | + ) { |
| 147 | + return value; |
| 148 | + } |
| 149 | + const nounPhrase = match[2].split(/\s+(?:by|for|from|in|inside|on|to|with)\s+/iu)[0] ?? match[2]; |
| 150 | + const firstNoun = nounPhrase.split(/\s+/u)[0]?.toLocaleLowerCase() ?? ""; |
| 151 | + const noun = nounPhrase.split(/\s+/u).at(-1)?.toLocaleLowerCase() ?? ""; |
| 152 | + if ( |
| 153 | + !noun || |
| 154 | + UNCOUNTABLE_NOUNS.has(firstNoun) || |
| 155 | + UNCOUNTABLE_NOUNS.has(noun) || |
| 156 | + isPluralNoun(firstNoun) || |
| 157 | + isPluralNoun(noun) |
| 158 | + ) { |
| 159 | + return value; |
| 160 | + } |
| 161 | + const article = articleFor(match[2]); |
| 162 | + return `${match[1]} ${article} ${match[2]}`; |
| 163 | +} |
| 164 | + |
| 165 | +function articleFor(value: string): "a" | "an" { |
| 166 | + if (CONSONANT_SOUND_VOWEL_WORDS.test(value)) { |
| 167 | + return "a"; |
| 168 | + } |
| 169 | + return /^[aeiou]/iu.test(value) ? "an" : "a"; |
| 170 | +} |
| 171 | + |
| 172 | +function isPluralNoun(value: string): boolean { |
| 173 | + return ( |
| 174 | + IRREGULAR_PLURAL_NOUNS.has(value) || |
| 175 | + (value.endsWith("s") && !value.endsWith("ss") && !SINGULAR_NOUNS_ENDING_IN_S.has(value)) |
| 176 | + ); |
| 177 | +} |
0 commit comments