Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,23 @@ class LoadSkillSkill @Inject constructor(
override suspend fun execute(call: SkillCall): SkillResult {
val skillName = call.arguments["skill_name"]?.takeIf { it.isNotBlank() }
?: return SkillResult.Failure(name, "Missing required parameter: skill_name.")
// Calendar actions are handled by run_intent, not a standalone skill
if (skillName.equals("calendar", ignoreCase = true)) {
val runIntentSkill = skillRegistry.get().get("run_intent")
if (runIntentSkill != null) {
return SkillResult.Success(
"Calendar actions are handled through run_intent. " +
"Use runIntent(intentName=\"create_calendar_event\", parameters={...}).\n\n" +
runIntentSkill.fullInstructions
)
}
}
val skill = skillRegistry.get().get(skillName)
?: return SkillResult.Failure(
name,
"Unknown skill: '$skillName'. Available: run_intent, get_weather, query_wikipedia, save_memory, search_memory, get_system_info, run_js",
"Unknown skill: '$skillName'. Available: run_intent, get_weather, " +
"query_wikipedia, save_memory, search_memory, get_system_info, run_js. " +
"Hint: calendar, alarm, SMS, and other device actions use run_intent."
)
return SkillResult.Success(skill.fullInstructions)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,17 @@ class QuickIntentRouter(
paramExtractor = { _, _ -> emptyMap() },
),

// "add something to the calendar for June 9th" / "put lunch in my calendar tomorrow"
IntentPattern(
intentName = "create_calendar_event",
regex = Regex(
"""(?:add|create|schedule|put|book)\s+(?!(?:calendar\s+)?voice\s+memo\b)(?!note\b)(.{3,60}?)\s+(?:to|in|on|into)\s+(?:the\s+|my\s+)?calendar\b""",
RegexOption.IGNORE_CASE,
),
paramExtractor = { _, raw -> extractCalendarHints(raw) },
requiredSlots = slotContract("create_calendar_event"),
),



// ── Calendar ──
Expand Down Expand Up @@ -4334,11 +4345,11 @@ class QuickIntentRouter(
// Fall back to the noun phrase immediately after the verb+article, stopping
// before any temporal keyword (e.g. "schedule a dentist appointment Friday").
val titleFromFor = Regex(
"""(?:^|\s)for\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:at|from|on|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""",
"""(?:^|\s)for\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:at|from|on|to|in|into|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""",
RegexOption.IGNORE_CASE,
).find(raw)
val titleFromVerb = Regex(
"""(?:add|create|schedule|put|book|set(?:\s+up)?)\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:for|at|from|on|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""",
"""(?:add|create|schedule|put|book|set(?:\s+up)?)\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:for|at|from|on|to|in|into|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""",
RegexOption.IGNORE_CASE,
).find(raw)
val DATE_WORDS = setOf(
Expand All @@ -4354,6 +4365,7 @@ class QuickIntentRouter(
"booking",
"invite",
"entry",
"something",
)
val rawTitle = run {
val fromFor = titleFromFor?.groupValues?.get(1)?.trim()
Expand Down Expand Up @@ -4385,6 +4397,32 @@ class QuickIntentRouter(
)
dateRegex.find(lower)?.value?.trim()?.let { params["date"] = it }

// ── Date: ordinal and explicit dates ("9th of june", "June 9th") ──
if (!params.containsKey("date")) {
val ordinalDateRegex = Regex(
"""\b(?:the\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b""",
RegexOption.IGNORE_CASE,
)
ordinalDateRegex.find(raw)?.let { match ->
val day = match.groupValues[1]
val month = match.groupValues[2].lowercase()
.replaceFirstChar { c -> c.uppercase() }
params["date"] = "$day $month"
}
}
if (!params.containsKey("date")) {
val monthFirstRegex = Regex(
"""\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+(\d{1,2})(?:st|nd|rd|th)?\b""",
RegexOption.IGNORE_CASE,
)
monthFirstRegex.find(raw)?.let { match ->
val month = match.groupValues[1].lowercase()
.replaceFirstChar { c -> c.uppercase() }
val day = match.groupValues[2]
params["date"] = "$day $month"
}
}

// ── Time: "at 2pm", "for 2pm", "at 10:30am", "at 10:30 p.m.", "at noon/midnight", "at 10" ─
// Bare hours (no am/pm) are normalised to HH:00 so resolveTime() can parse.
val timeRegex = Regex(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2791,7 +2791,7 @@ class NativeIntentHandler @Inject constructor(
* Returns null if the string cannot be resolved to a valid date.
*/
private fun resolveDate(dateStr: String): LocalDate? {
val input = dateStr.trim()
val input = dateStr.trim().replace(Regex("""\b(\d{1,2})(?:st|nd|rd|th)\b"""), "$1")
val normalized = input.lowercase()
val today = LocalDate.now()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ class QuickIntentRouterTest {
input: String,
expectedTitle: String,
expectedDate: String,
expectedTime: String,
expectedTime: String?,
) {
val result = regexOnlyRouter.route(input)
assertRegexMatch(result, "create_calendar_event", input)
Expand Down Expand Up @@ -2437,6 +2437,10 @@ class QuickIntentRouterTest {
Arguments.of("set up a dentist appointment tomorrow at 5pm", "Dentist Appointment", "tomorrow", "5pm"),
Arguments.of("schedule a team meeting on friday at 2pm", "Team Meeting", "friday", "2pm"),
Arguments.of("set up a dentist appointment sunday at 3:00 p.m.", "Dentist Appointment", "sunday", "3:00 p.m."),
Arguments.of("add dentist to my calendar on Friday at 2pm", "Dentist", "friday", "2pm"),
Arguments.of("put lunch in my calendar tomorrow", "Lunch", "tomorrow", null),
Arguments.of("add lunch to my calendar on June 9th at 2pm", "Lunch", "9 June", "2pm"),
Arguments.of("add lunch to my calendar on 9th of June", "Lunch", "9 June", null),
)

@JvmStatic
Expand All @@ -2447,12 +2451,14 @@ class QuickIntentRouterTest {
Arguments.of("set an appointment for 3:00 p.m. Sunday"),
Arguments.of("Appointment for 3:00 p.m. Sunday"),
Arguments.of("Set an appointment up for 3:00 p.m. On Monday"),
Arguments.of("add something to my calendar"),
)

@JvmStatic
fun calendarNeedsDatePhrases(): Stream<Arguments> = Stream.of(
Arguments.of("set up a dentist appointment", "Dentist Appointment"),
Arguments.of("schedule a budget meeting", "Budget Meeting"),
Arguments.of("add dentist appointment to my calendar", "Dentist Appointment"),
)

// ── DND ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -3664,7 +3670,6 @@ class QuickIntentRouterTest {
@JvmStatic
fun e4bFallthroughPhrases(): Stream<Arguments> = Stream.of(
// Calendar (needs NLU for date/time/title extraction)
Arguments.of("add dentist appointment to my calendar"),
// Wikipedia / knowledge
Arguments.of("tell me about the history of New Zealand"),
Arguments.of("who invented the internet"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2312,4 +2312,22 @@ class NativeIntentHandlerTest {

return cursor
}

@Test
fun `resolveDate strips ordinal suffixes from date strings`() {
val method = NativeIntentHandler::class.java.getDeclaredMethod(
"resolveDate",
String::class.java,
).also { it.isAccessible = true }

val today = java.time.LocalDate.now()

// "9th June" → resolveDate should strip "th" and parse "9 June"
val result = method.invoke(handler, "9th June")
assertNotNull(result, "Expected non-null for '9th June'")
val localDate = result as java.time.LocalDate
assertEquals(today.year, localDate.year, "Year defaults to current year")
assertEquals(6, localDate.monthValue, "Month should be June (6)")
assertEquals(9, localDate.dayOfMonth, "Day should be 9")
}
}
Loading