feat: add timer and alarm support via two-stage LLM router#3
Conversation
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 4 minutes and 5 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (16)
📝 WalkthroughWalkthroughAdds end-to-end timer/alarm support: LLM router + extraction templates, benchmark tooling, DB schema (durations, archived memos), native Android/iOS timer-alarm creation, UI screens/widgets and settings, AI pipeline routing/no-speech filtering, repository and provider updates, tests, and a long spec document. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User
participant App as Voice Memo App
participant RouterLLM as Router LLM
participant ExtractorLLM as Extractor LLM
participant Parser as Chrono Parser
participant Platform as Platform API
User->>App: Submit voice memo
App->>RouterLLM: classify via routerTemplate
RouterLLM-->>App: { "route": "timer_alarm" } or { "route": "voice_memo" }
alt route == "timer_alarm"
App->>ExtractorLLM: run timerAlarmTemplate
ExtractorLLM-->>Parser: JSON extraction
Parser-->>App: ChronoLlmExtraction(intent,title,durationSeconds,...)
alt intent == "timer"
App->>Platform: createTimer(durationSeconds, title, skipUi)
Platform-->>User: Timer created/started
else intent == "alarm"
App->>Platform: createAlarm(scheduledAt, title, skipUi)
Platform-->>User: Alarm scheduled
end
else route == "voice_memo"
App->>ExtractorLLM: run voice-memo template
ExtractorLLM-->>Parser: JSON extraction
Parser-->>App: ChronoLlmExtraction(reminder/event/note,...)
App->>App: persist actions and possibly create platform reminders/events
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Implement timer/alarm creation from voice memos using a two-stage LLM routing approach: a lightweight router prompt classifies transcripts as timer_alarm/voice_memo/mixed, then routes to the appropriate extraction prompt. Timers and alarms are created via Android AlarmClock intents (with EXTRA_SKIP_UI for silent background operation) and iOS Clock app URL schemes. Key changes: - Two-stage router in LlmService (routerTemplate → timerAlarmTemplate/compactTemplate) - Android: SET_TIMER/SET_ALARM intents, manifest permissions and queries - iOS: Clock app URL scheme support - DB schema v3→v4: durationSeconds column on extracted_actions - Crash recovery: reset stuck processing memos on app startup - UI: timer/alarm action cards with Create/Dismiss, retry on failure - Settings: test buttons for timer/alarm creation - Fix raw JSON showing as summary for timer/alarm memos - Skip auto-create and watch notification for plain notes - Benchmark results for timer/alarm test cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
597f6fa to
ce71fdc
Compare
There was a problem hiding this comment.
Pull request overview
Adds timer/alarm support to the voice memo AI pipeline using a two-stage LLM router (router → dedicated timer/alarm extraction), persists timer duration in the DB, updates UI rendering/creation flows, and extends the AI testbench/specs to cover the new intents.
Changes:
- Introduces router + dedicated timer/alarm extraction prompt and adds
durationSecondssupport end-to-end (parser → models → DB → UI). - Implements platform bridges for creating timers/alarms (Android AlarmClock intents; iOS Clock URL schemes) and updates UI to display/trigger these actions.
- Extends AI testbench benchmarks/spec documentation with timer/alarm cases and router benchmarking.
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart | Adds cleanSummary() to avoid showing raw JSON as the memo preview/summary. |
| zswatch_app/lib/ui/widgets/ai_debug_widgets.dart | Shows timer duration in chrono debug output. |
| zswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dart | Adds retry UI for failed AI, cleans displayed summaries, and adds timer/alarm action card rendering. |
| zswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dart | Adds local “Timer/Alarm test” buttons that invoke platform creation without a watch. |
| zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart | Persists durationSeconds, skips watch notifications for “note-only” tasks, and resets stuck memos before processing. |
| zswatch_app/lib/services/ai/llm_service.dart | Adds two-stage routing (router → prompt selection) and maps timer/alarm to extracted actions + friendly summaries. |
| zswatch_app/lib/services/ai/extracted_action_creation_service.dart | Extends action drafts with duration/skipUi and adds timer/alarm creation plumbing + messaging. |
| zswatch_app/lib/services/ai/ai_debug_info.dart | Adds durationSeconds to chrono debug model. |
| zswatch_app/lib/providers/voice_memo_providers.dart | Skips auto-create for “notes” (tasks without time). |
| zswatch_app/lib/data/repositories/voice_memo_repository.dart | Exposes DB method to reset stuck processing states. |
| zswatch_app/lib/data/repositories/extracted_action_repository.dart | Writes/reads durationSeconds for extracted actions. |
| zswatch_app/lib/data/models/extracted_action.dart | Adds timer/alarm action types and durationSeconds. |
| zswatch_app/lib/data/models/extracted_action.freezed.dart | Regenerates Freezed model for new durationSeconds field. |
| zswatch_app/lib/data/database/tables/extracted_actions_table.dart | Adds nullable duration_seconds column. |
| zswatch_app/lib/data/database/app_database.dart | Bumps schema v3→v4, migrates duration_seconds, and resets stuck memos on DB open. |
| zswatch_app/lib/data/database/app_database.g.dart | Regenerated Drift DB code for new column. |
| zswatch_app/ios/Runner/AppDelegate.swift | Handles timer/alarm action creation by opening Clock app URL schemes. |
| zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt | Handles timer/alarm action creation via AlarmClock intents (with EXTRA_SKIP_UI). |
| zswatch_app/android/app/src/main/AndroidManifest.xml | Adds SET_ALARM permission and package visibility queries for timer/alarm intents. |
| specs/ai-tasks/alarms-and-timers.md | Adds a detailed draft spec for alarms/timers and routing strategy. |
| packages/chrono_ai_flow/lib/src/prompt_template.dart | Adds routerTemplate and timerAlarmTemplate. |
| packages/chrono_ai_flow/lib/src/parser.dart | Parses duration_seconds and normalizes timer/alarm intent variants. |
| packages/chrono_ai_flow/lib/src/models.dart | Adds durationSeconds to extraction model. |
| ai_testbench/lib/services/model_benchmark_service.dart | Adds timer/alarm benchmark cases and duration validation fields. |
| ai_testbench/lib/router_benchmark_main.dart | Adds a headless router benchmark runner. |
| ai_testbench/lib/main.dart | Adds --headless-router mode dispatch. |
| ai_testbench/lib/benchmark_main.dart | Serializes duration match details into JSON output. |
| ai_testbench/benchmark_results/exp2_extended_timer_alarm.json | Adds benchmark output artifact for extended timer/alarm experiment. |
| ai_testbench/benchmark_results/exp1_baseline_timer.json | Adds benchmark output artifact for baseline timer experiment. |
| ai_testbench/benchmark_results/exp1_baseline_alarm.json | Adds benchmark output artifact for baseline alarm experiment. |
| ai_testbench/README.md | Documents new (timer/alarm) benchmark modes/options. |
Comments suppressed due to low confidence (1)
ai_testbench/lib/main.dart:37
- This entrypoint adds
--headless-routerbut does not implement the--headless-timer/--prompt-timerflags that are documented inai_testbench/README.md. If those flags are intended, add handling here (or route tobenchmark_mainwith the right case/prompt selection); otherwise update the README to avoid suggesting unsupported CLI options.
void main(List<String> args) async {
// Headless mode: run router pre-classifier benchmark
if (args.contains('--headless-router')) {
await runRouterBenchmark(args);
exit(exitCode);
}
// Headless mode: run time extraction tests from CLI
if (args.contains('--headless-time')) {
await runHeadlessTimeExtraction(args);
exit(exitCode);
}
// Headless mode: run correction benchmark from CLI
if (args.contains('--headless-correction')) {
WidgetsFlutterBinding.ensureInitialized();
await runHeadlessCorrectionBenchmark(args);
exit(exitCode);
}
// Headless mode: run model benchmark from CLI
if (args.contains('--headless') ||
Platform.environment['AI_BENCH_HEADLESS'] == '1') {
await model_bench.main(args);
exit(exitCode);
}
| private fun createAlarmViaIntent(triggerAtMillis: Long?, label: String, skipUi: Boolean = true): Map<String, Any?> { | ||
| val intent = android.content.Intent(android.provider.AlarmClock.ACTION_SET_ALARM).apply { | ||
| if (triggerAtMillis != null) { | ||
| val cal = java.util.Calendar.getInstance().apply { timeInMillis = triggerAtMillis } | ||
| putExtra(android.provider.AlarmClock.EXTRA_HOUR, cal.get(java.util.Calendar.HOUR_OF_DAY)) | ||
| putExtra(android.provider.AlarmClock.EXTRA_MINUTES, cal.get(java.util.Calendar.MINUTE)) | ||
| } | ||
| if (label.isNotEmpty()) { | ||
| putExtra(android.provider.AlarmClock.EXTRA_MESSAGE, label) | ||
| } | ||
| putExtra(android.provider.AlarmClock.EXTRA_SKIP_UI, skipUi) | ||
| } |
There was a problem hiding this comment.
createAlarmViaIntent allows triggerAtMillis to be null while also supporting EXTRA_SKIP_UI=true. Without explicitly providing EXTRA_HOUR/EXTRA_MINUTES, behavior is device/clock-app dependent and may create an incorrect alarm or fail silently. Consider requiring a non-null trigger time when skipUi is true (and returning a clear error if it's missing).
| **Timer/alarm benchmark** (uses extended 5-intent prompt with timer/alarm cases): | ||
| ```bash | ||
| ./build/linux/x64/release/bundle/ai_testbench --headless-timer --model Qwen3.5-2B-Q4_K_M.gguf | ||
| ``` | ||
|
|
||
| ### CLI Options | ||
|
|
||
| | Flag | Description | | ||
| |------|-------------| | ||
| | `--headless` | Run structured extraction benchmark (all models) | | ||
| | `--headless-time` | Run time extraction benchmark | | ||
| | `--headless-timer` | Run timer/alarm benchmark (5-intent prompt, timer/alarm cases only) | | ||
| | `--prompt-timer` | Use the 5-intent prompt (with `--headless` to run all cases for regression testing) | |
There was a problem hiding this comment.
The README documents a --headless-timer mode, but ai_testbench/lib/main.dart doesn't handle this flag (only --headless, --headless-time, --headless-correction, --headless-router). Either implement --headless-timer (and wire it to ModelBenchmarkService.timerAlarmCases + the appropriate prompt) or remove/update the documentation.
| **Timer/alarm benchmark** (uses extended 5-intent prompt with timer/alarm cases): | |
| ```bash | |
| ./build/linux/x64/release/bundle/ai_testbench --headless-timer --model Qwen3.5-2B-Q4_K_M.gguf | |
| ``` | |
| ### CLI Options | |
| | Flag | Description | | |
| |------|-------------| | |
| | `--headless` | Run structured extraction benchmark (all models) | | |
| | `--headless-time` | Run time extraction benchmark | | |
| | `--headless-timer` | Run timer/alarm benchmark (5-intent prompt, timer/alarm cases only) | | |
| | `--prompt-timer` | Use the 5-intent prompt (with `--headless` to run all cases for regression testing) | | |
| ### CLI Options | |
| | Flag | Description | | |
| |------|-------------| | |
| | `--headless` | Run structured extraction benchmark (all models) | | |
| | `--headless-time` | Run time extraction benchmark | |
| **Timer/alarm benchmark** (uses extended 5-intent prompt with timer/alarm cases): | ||
| ```bash | ||
| ./build/linux/x64/release/bundle/ai_testbench --headless-timer --model Qwen3.5-2B-Q4_K_M.gguf | ||
| ``` | ||
|
|
||
| ### CLI Options | ||
|
|
||
| | Flag | Description | | ||
| |------|-------------| | ||
| | `--headless` | Run structured extraction benchmark (all models) | | ||
| | `--headless-time` | Run time extraction benchmark | | ||
| | `--headless-timer` | Run timer/alarm benchmark (5-intent prompt, timer/alarm cases only) | | ||
| | `--prompt-timer` | Use the 5-intent prompt (with `--headless` to run all cases for regression testing) | |
There was a problem hiding this comment.
The CLI options list --headless-timer and --prompt-timer, but there’s no implementation for these flags in the CLI entrypoints (e.g., ai_testbench/lib/main.dart / benchmark_main.dart). Please align the docs with the implemented flags, or add support for these options in the CLI parser/runner.
| **Timer/alarm benchmark** (uses extended 5-intent prompt with timer/alarm cases): | |
| ```bash | |
| ./build/linux/x64/release/bundle/ai_testbench --headless-timer --model Qwen3.5-2B-Q4_K_M.gguf | |
| ``` | |
| ### CLI Options | |
| | Flag | Description | | |
| |------|-------------| | |
| | `--headless` | Run structured extraction benchmark (all models) | | |
| | `--headless-time` | Run time extraction benchmark | | |
| | `--headless-timer` | Run timer/alarm benchmark (5-intent prompt, timer/alarm cases only) | | |
| | `--prompt-timer` | Use the 5-intent prompt (with `--headless` to run all cases for regression testing) | | |
| ### CLI Options | |
| | Flag | Description | | |
| |------|-------------| | |
| | `--headless` | Run structured extraction benchmark (all models) | | |
| | `--headless-time` | Run time extraction benchmark | |
| static const ChronoLlmParser _parser = ChronoLlmParser(); | ||
|
|
||
| /// Prompt template to use for extraction. | ||
| String promptTemplate = ChronoPromptTemplate.defaultTemplate; |
There was a problem hiding this comment.
benchmarkCases now includes timer/alarm expectations, but promptTemplate defaults to ChronoPromptTemplate.defaultTemplate (which only supports reminder/event/note). As a result, the default benchmark run will systematically fail the new timer/alarm cases unless the runner switches prompts (or performs routing) based on the case set. Consider adding an explicit CLI switch that sets promptTemplate + selectedCases (e.g. timer/alarm-only), or implement per-case routing between compactTemplate and timerAlarmTemplate.
| String promptTemplate = ChronoPromptTemplate.defaultTemplate; | |
| /// Use the compact template by default so all intents in [benchmarkCases] | |
| /// (including timer/alarm) are supported in the default benchmark run. | |
| String promptTemplate = ChronoPromptTemplate.compactTemplate; |
| ChronoPromptTemplate.timerAlarmTemplate, | ||
| transcript: effectiveTranscript, | ||
| ); | ||
| promptStrategy = 'router→timer_alarm'; |
There was a problem hiding this comment.
The router template allows a mixed route, but the routing logic only checks for routeResult == 'timer_alarm' and otherwise falls back to the voice-memo prompt. That means mixed inputs won’t extract timer/alarm items. Consider handling mixed explicitly (e.g., run both extraction prompts and merge results, or introduce a combined prompt).
| promptStrategy = 'router→timer_alarm'; | |
| promptStrategy = 'router→timer_alarm'; | |
| } else if (routeResult == 'mixed') { | |
| // For mixed inputs, combine timer/alarm-focused and voice-memo-focused | |
| // instructions into a single prompt so we can extract both kinds of items. | |
| final timerPromptPart = ChronoPromptTemplate.render( | |
| ChronoPromptTemplate.timerAlarmTemplate, | |
| transcript: effectiveTranscript, | |
| ); | |
| final memoPromptPart = _buildClassifyPrompt( | |
| effectiveTranscript, | |
| effectiveCtx: effectiveCtx, | |
| ); | |
| prompt = '$timerPromptPart\n\n$memoPromptPart'; | |
| promptStrategy = usesFullPrompt(effectiveCtx) | |
| ? 'router→mixed/combined-full' | |
| : usesEmergencyCompactPrompt(effectiveCtx) | |
| ? 'router→mixed/combined-emergency-compact' | |
| : 'router→mixed/combined-compact'; |
| switch (actionType) { | ||
| case ExtractedActionType.timer: | ||
| case ExtractedActionType.alarm: | ||
| // Timer/alarm use system intents — no calendar permissions needed. | ||
| return Permission.notification; | ||
| case ExtractedActionType.calendarEvent: |
There was a problem hiding this comment.
On iOS, timer/alarm creation is implemented by opening the Clock app URLs in AppDelegate and does not require notification permission. Returning Permission.notification here means users who deny notification permission will be blocked from creating timers/alarms even though creation could proceed. Consider skipping permission checks for timer/alarm on iOS (or only requesting notification permission once the implementation actually relies on notifications).
| if let url = URL(string: "clock-timer://") { | ||
| UIApplication.shared.open(url, options: [:]) { success in | ||
| result([ | ||
| "platformId": nil, | ||
| "targetType": "timer", | ||
| "syncDisabled": false, | ||
| ] as [String: Any?]) | ||
| } | ||
| } else { | ||
| result([ | ||
| "platformId": nil, | ||
| "targetType": "timer", | ||
| "syncDisabled": false, | ||
| ] as [String: Any?]) | ||
| } | ||
| } | ||
|
|
||
| private func createAlarmViaClockApp(scheduledAtMillis: Double?, label: String, result: @escaping FlutterResult) { | ||
| // iOS doesn't have a public API for setting alarms programmatically. | ||
| // Open the Clock app's alarm tab as the best available option. | ||
| if let url = URL(string: "clock-alarm://") { | ||
| UIApplication.shared.open(url, options: [:]) { success in | ||
| result([ | ||
| "platformId": nil, | ||
| "targetType": "alarm", | ||
| "syncDisabled": false, | ||
| ] as [String: Any?]) | ||
| } | ||
| } else { | ||
| result([ | ||
| "platformId": nil, | ||
| "targetType": "alarm", | ||
| "syncDisabled": false, | ||
| ] as [String: Any?]) |
There was a problem hiding this comment.
createTimerViaClockApp always returns a successful "timer" result even if the URL scheme cannot be opened (the success callback is ignored), and it doesn't use durationSeconds/label at all. This can mislead the Dart side into showing "Timer started" when no timer was actually created. Consider checking success (or canOpenURL) and returning an error/explicit "opened clock app" status when the Clock app couldn't be opened or when the action can't be created programmatically.
| if let url = URL(string: "clock-timer://") { | |
| UIApplication.shared.open(url, options: [:]) { success in | |
| result([ | |
| "platformId": nil, | |
| "targetType": "timer", | |
| "syncDisabled": false, | |
| ] as [String: Any?]) | |
| } | |
| } else { | |
| result([ | |
| "platformId": nil, | |
| "targetType": "timer", | |
| "syncDisabled": false, | |
| ] as [String: Any?]) | |
| } | |
| } | |
| private func createAlarmViaClockApp(scheduledAtMillis: Double?, label: String, result: @escaping FlutterResult) { | |
| // iOS doesn't have a public API for setting alarms programmatically. | |
| // Open the Clock app's alarm tab as the best available option. | |
| if let url = URL(string: "clock-alarm://") { | |
| UIApplication.shared.open(url, options: [:]) { success in | |
| result([ | |
| "platformId": nil, | |
| "targetType": "alarm", | |
| "syncDisabled": false, | |
| ] as [String: Any?]) | |
| } | |
| } else { | |
| result([ | |
| "platformId": nil, | |
| "targetType": "alarm", | |
| "syncDisabled": false, | |
| ] as [String: Any?]) | |
| debugPrint("[AppDelegate] Requested timer via Clock app. durationSeconds=\(durationSeconds), label=\(label)") | |
| guard let url = URL(string: "clock-timer://") else { | |
| result( | |
| FlutterError( | |
| code: "INVALID_URL", | |
| message: "Unable to construct Clock timer URL.", | |
| details: nil | |
| ) | |
| ) | |
| return | |
| } | |
| UIApplication.shared.open(url, options: [:]) { success in | |
| if success { | |
| result([ | |
| "platformId": nil, | |
| "targetType": "timer", | |
| "syncDisabled": false, | |
| ] as [String: Any?]) | |
| } else { | |
| debugPrint("[AppDelegate] Failed to open Clock app for timer.") | |
| result( | |
| FlutterError( | |
| code: "CLOCK_APP_UNAVAILABLE", | |
| message: "Unable to open the Clock app timer tab.", | |
| details: nil | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| private func createAlarmViaClockApp(scheduledAtMillis: Double?, label: String, result: @escaping FlutterResult) { | |
| // iOS doesn't have a public API for setting alarms programmatically. | |
| // Open the Clock app's alarm tab as the best available option. | |
| debugPrint("[AppDelegate] Requested alarm via Clock app. scheduledAtMillis=\(String(describing: scheduledAtMillis)), label=\(label)") | |
| guard let url = URL(string: "clock-alarm://") else { | |
| result( | |
| FlutterError( | |
| code: "INVALID_URL", | |
| message: "Unable to construct Clock alarm URL.", | |
| details: nil | |
| ) | |
| ) | |
| return | |
| } | |
| UIApplication.shared.open(url, options: [:]) { success in | |
| if success { | |
| result([ | |
| "platformId": nil, | |
| "targetType": "alarm", | |
| "syncDisabled": false, | |
| ] as [String: Any?]) | |
| } else { | |
| debugPrint("[AppDelegate] Failed to open Clock app for alarm.") | |
| result( | |
| FlutterError( | |
| code: "CLOCK_APP_UNAVAILABLE", | |
| message: "Unable to open the Clock app alarm tab.", | |
| details: nil | |
| ) | |
| ) | |
| } |
| private func createAlarmViaClockApp(scheduledAtMillis: Double?, label: String, result: @escaping FlutterResult) { | ||
| // iOS doesn't have a public API for setting alarms programmatically. | ||
| // Open the Clock app's alarm tab as the best available option. | ||
| if let url = URL(string: "clock-alarm://") { | ||
| UIApplication.shared.open(url, options: [:]) { success in | ||
| result([ | ||
| "platformId": nil, | ||
| "targetType": "alarm", | ||
| "syncDisabled": false, | ||
| ] as [String: Any?]) | ||
| } |
There was a problem hiding this comment.
createAlarmViaClockApp ignores both scheduledAtMillis and the success result from UIApplication.open, yet reports success back to Flutter. This makes the UI think an alarm was set even when only a deep link attempt occurred (or failed). Consider validating that the Clock app was opened and returning a failure when it wasn't, or adjust the response so the app doesn't treat this as an alarm having been created.
| private fun createTimerViaIntent(durationSeconds: Int, label: String, skipUi: Boolean = true): Map<String, Any?> { | ||
| val intent = android.content.Intent(android.provider.AlarmClock.ACTION_SET_TIMER).apply { | ||
| putExtra(android.provider.AlarmClock.EXTRA_LENGTH, durationSeconds) | ||
| if (label.isNotEmpty()) { | ||
| putExtra(android.provider.AlarmClock.EXTRA_MESSAGE, label) | ||
| } | ||
| putExtra(android.provider.AlarmClock.EXTRA_SKIP_UI, skipUi) | ||
| } |
There was a problem hiding this comment.
createTimerViaIntent accepts durationSeconds values like 0 (defaulted from null) and still sets EXTRA_SKIP_UI. Many clock apps treat 0 as invalid or behave unexpectedly. Consider validating durationSeconds > 0 and returning an error when it's missing/invalid, especially when skipUi is true.
There was a problem hiding this comment.
Actionable comments posted: 20
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
zswatch_app/lib/services/ai/extracted_action_creation_service.dart (1)
319-325:⚠️ Potential issue | 🟠 Major
openCreatedAction()still no-ops for timers and alarms.These new cases are mapped to
'alarm', but_openCreatedCalendarEntryIfSupported()immediately returns for anything other than'calendar_event'or'calendar_reminder'. If the UI exposes the Open button for a created timer/alarm, tapping it silently does nothing.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@zswatch_app/lib/services/ai/extracted_action_creation_service.dart` around lines 319 - 325, The Open action is still a no-op for ExtractedActionType.timer and ExtractedActionType.alarm because they’re mapped to 'alarm' but _openCreatedCalendarEntryIfSupported() only handles 'calendar_event' and 'calendar_reminder'; modify openCreatedAction() to route timers/alarms to a proper opener instead of the calendar opener: change the targetType mapping to produce distinct values (e.g., 'alarm' or 'timer') or check action.actionType directly, then call a new helper like _openCreatedAlarmIfSupported()/ _openCreatedTimerIfSupported() (or extend _openCreatedCalendarEntryIfSupported() to accept 'alarm'/'timer' and handle platform-specific opening) so tapping Open on created timers/alarms actually invokes the correct platform-specific open logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ai_testbench/lib/router_benchmark_main.dart`:
- Around line 130-135: The latency summaries mix different case sets
(avgRouterMs uses routerTimes, avgTotalMs uses the timer_alarm subset, and
avgSingleMs uses the first five voice_memo cases), producing invalid
comparisons; update the calculations so comparisons use the same consistent
dataset or compute and label per-subset metrics explicitly: e.g., create clear
collections like routerTimes (all router cases), timerAlarmTimes (cases where
case.type == 'timer_alarm'), voiceMemoTimes (cases where case.type ==
'voice_memo') and compute avgRouterMs, avgTimerAlarmMs, avgVoiceMemoMs from
those collections, then use matching counts (routerCorrect vs routerCases.length
or subsetCorrect vs subset.length) when printing summaries (references:
avgRouterMs, avgTotalMs, avgSingleMs, routerTimes, routerCorrect, cases,
timer_alarm, voice_memo).
- Around line 150-196: The router result is measured but not used to gate
extraction, so misrouted cases are counted as successes; also success only
checks first.intent which misses multi-item or wrong-duration failures. Change
the loop to parse routerResult.output (e.g., inspect routerResult.output JSON or
ChronoPromptTemplate router response) and only run extraction when the router
route indicates timer/alarm; if router indicates a different route mark the case
failed immediately and count router time. After running parser.parse on
extractResult.output, make intentOk require that the router-declared route
matches the parsed extractions and that at least one extraction has intent ==
'timer' or 'alarm' with a non-null duration/time (handling multi-item by
scanning all parseResult.extractions). Ensure timing (routerMs and extractMs)
reflect whether extraction actually ran and that pipeline failures include
router misclassification or missing/invalid extractions.
- Around line 20-33: Check that Directory(modelDir) exists and is a directory
before calling listSync() to avoid a FileSystemException; update the modelPaths
construction to first call Directory(modelDir).existsSync() (or wrap
Directory(modelDir).listSync() in a try/catch) and when the directory is missing
or an exception occurs, write a friendly message via
stdout.writeln('[RouterBench] No .gguf models found' or a specific error) and
set exitCode = 1 then return; adjust code around modelPaths,
Directory(modelDir).listSync(), and the early-return branch so the friendly
message runs when the directory is absent or listSync() fails.
In `@ai_testbench/lib/services/model_benchmark_service.dart`:
- Around line 910-916: The two alarm fixtures in the BenchmarkCase.single
entries (e.g., the case with name 'en_alarm_morning' and the Swedish alarm case
around lines 946-953) use expectedDateTime set to March 11, 2026 but with
referenceTime = DateTime(2026,3,11,10,15) they should resolve to the next day;
update the expectedDateTime for those BenchmarkCase.single instances to
DateTime(2026, 3, 12, 7, 30) so the expected intent/time matches the sensible
next-day resolution.
- Around line 214-215: The service uses the mutable field promptTemplate
(ChronoPromptTemplate.defaultTemplate) independently of timerAlarmCases, so runs
that select timerAlarmCases still use whatever promptTemplate is set; update
ModelBenchmarkService (the constructor or runForModels) so that when
timerAlarmCases is enabled it explicitly sets promptTemplate =
ChronoPromptTemplate.timerAlarmTemplate (or otherwise selects that template)
before rendering prompts in runForModels; ensure the same binding is applied in
any code paths that construct ModelBenchmarkService (e.g., the default runner vs
router benchmark path) so timer/alarm benchmarks always use
ChronoPromptTemplate.timerAlarmTemplate instead of the global/default
promptTemplate.
In `@ai_testbench/README.md`:
- Around line 87-90: The README documents flags `--headless-timer` and
`--prompt-timer` that are not parsed or used; update the CLI parsing in
main.dart to accept these flags (e.g., add options for "--headless-timer" and
"--prompt-timer" to the argument parser) and wire them into the benchmark
invocation (pass the parsed booleans into the benchmark runner or
TimerAlarmBenchmark class and branch the execution where benchmarks are
selected), or alternatively remove the flags from the README; specifically,
modify the argument parsing logic in main.dart (and any
BenchmarkRunner/TimerAlarmBenchmark constructors or run methods) to expose and
forward the two flags so the timer/alarm benchmark executes when set.
In `@packages/chrono_ai_flow/lib/src/parser.dart`:
- Around line 72-90: When building the ChronoLlmExtraction, add validation to
reject incomplete timer/alarm results: if intent == 'timer' ensure
durationSeconds is non-null (rawDuration parsed successfully) and if intent ==
'alarm' ensure at least one of datetimeExpressionOriginal or
datetimeExpressionEnglish is non-null/non-empty; if those checks fail, return
null (or otherwise short-circuit the parser) instead of constructing
ChronoLlmExtraction so incomplete objects don't pass
shouldRetryInvalidChronoOutput().
In `@packages/chrono_ai_flow/lib/src/prompt_template.dart`:
- Line 233: The template currently injects the raw transcript into a quoted memo
line (Memo: "$promptPlaceholderTranscript"), which breaks if the transcript
contains quotes or newlines; update the template to use an unquoted block style
(e.g., a new indented or fenced block like Memo: followed by the raw transcript
on the next line) or ensure promptPlaceholderTranscript is properly escaped
before interpolation; locate the occurrences of the quoted memo string (the
literal containing promptPlaceholderTranscript at the lines shown and the
similar instance around 281) and replace them with the unquoted-block style or
add escaping logic where promptPlaceholderTranscript is formatted.
- Around line 243-257: The prompt in prompt_template.dart (the "Extract timer or
alarm details..." template) must explicitly require splitting multiple requests
into separate array objects; update this template to add a rule like the ones in
defaultTemplate/compactTemplate ensuring "If the memo contains multiple
timers/alarms, return one JSON object per timer/alarm in the array (do not
combine multiple intents into a single object)." Mention that each item must
follow the existing schema and that output must be an array with one object per
detected timer/alarm.
- Around line 225-231: The router can return "mixed" but llm_service.dart
currently falls through to the catch-all and only calls compactTemplate,
dropping timers; add an explicit else if (routeResult == 'mixed') branch in the
function that handles routeResult which 1) calls timerAlarmTemplate to extract
timer/alarm items, 2) calls compactTemplate to extract reminder/event/note
items, and 3) merges the two result arrays (preserving both sets of items)
before returning so inputs like "Set a timer for 5 minutes and buy milk" include
both timer and voice memo parts; reference the routeResult variable,
compactTemplate, timerAlarmTemplate and the existing catch-all path to insert
this branch.
In `@specs/ai-tasks/alarms-and-timers.md`:
- Around line 100-112: The fenced code blocks describing "timer" and "alarm" are
unlabeled and trigger markdownlint MD040; update each triple-backtick fence that
wraps the blocks containing the quoted terms "timer" and "alarm" to include a
language identifier (e.g., ```text or ```md) so the linter stops flagging them,
and apply the same fix to the other unlabeled fenced blocks referenced in the
comment (the additional blocks the reviewer listed) by adding an appropriate
language tag to each fence.
In `@zswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.kt`:
- Around line 696-707: createAlarmViaIntent currently only sets hour/minute and
thus loses absolute date info from triggerAtMillis; to fix, detect when
triggerAtMillis represents a specific absolute time (not a daily recurring time)
and schedule a one‑time alarm via AlarmManager instead of the ACTION_SET_ALARM
intent. Add or call a new helper (e.g., scheduleOneTimeAlarmUsingAlarmManager or
createOneTimeAlarmWithAlarmManager) from createAlarmViaIntent when
triggerAtMillis != null and the intended behavior is a single absolute
timestamp: build a PendingIntent (to a BroadcastReceiver/Service), use
AlarmManager.setExactAndAllowWhileIdle/setExact depending on API level, and keep
the existing intent-path only for UI-driven or recurring/daily alarms; update
createAlarmViaIntent to return consistent Map<String, Any?> metadata (or an
error) so callers know which path was taken.
In `@zswatch_app/ios/Runner/AppDelegate.swift`:
- Around line 67-75: The branch that calls createTimerViaClockApp and
createAlarmViaClockApp currently returns a success to Dart without confirming
the Clock app actually opened or using the passed parameters; update
createTimerViaClockApp and createAlarmViaClockApp to build the correct clock URL
using durationSeconds, scheduledAtMillis and label, call
UIApplication.shared.open(_:options:completionHandler:), and only call
result.success(...) inside the completionHandler based on the Bool success
parameter (and call result.error(...) on failure); ensure the methods forward
the constructed URL to UIApplication.shared.open and do not unconditionally
return success as currently done in the code paths handling those actions.
In `@zswatch_app/lib/data/database/app_database.dart`:
- Around line 61-64: The v4 migration unconditionally adds
extractedActions.durationSeconds when from < 4 which duplicates the column for
pre-v2 upgrades because extracted_actions is created with that column in the v2+
schema; change the guard so the addColumn runs only for upgrades that started at
v2 or v3 (e.g., replace the condition with from >= 2 && from < 4) or otherwise
check that extractedActions.durationSeconds does not already exist before
calling m.addColumn(extractedActions, extractedActions.durationSeconds).
In `@zswatch_app/lib/services/ai/extracted_action_creation_service.dart`:
- Around line 385-403: The code incorrectly requests app notification permission
for iOS timer/alarm actions; update _permissionForActionType to return a
nullable Permission? and have it return null for ExtractedActionType.timer and
ExtractedActionType.alarm, then modify the caller (where permission is assigned
and _requestPermission is awaited) to skip calling _requestPermission when
permission == null; also ensure _failureMessageForActionType use is safe when
permission is null (only compute/pass failureMessage when requesting). This
touches the functions _permissionForActionType, the caller that awaits
_requestPermission, and _failureMessageForActionType so iOS timers/alarms bypass
notification gating.
In `@zswatch_app/lib/services/ai/llm_service.dart`:
- Around line 975-1026: The routing logic currently sends every non-timer case
to _buildClassifyPrompt which drops timer/alarm intent for router='mixed'; add
an explicit branch handling routeResult == 'mixed' in the prompt-construction
block (the same place that sets prompt and promptStrategy) so that mixed uses a
prompt that can represent both timers/alarms and voice memos (for example render
a new or existing combined template via ChronoPromptTemplate (e.g., a
mixedTemplate or by composing timerAlarmTemplate + voice memo template) or call
a new helper like _buildMixedClassifyPrompt), and set promptStrategy to
something like 'router→mixed' (and respect
usesFullPrompt/usesEmergencyCompactPrompt if applicable) so timer intents are
preserved for mixed inputs; update any callers/metrics labels
(_renderClassifyPrompt, _buildClassifyPrompt,
ChronoPromptTemplate.timerAlarmTemplate) accordingly.
- Around line 1495-1517: Change _friendlySummary and the code that materializes
native actions from ChronoLlmExtraction so we don't create timer/alarm outputs
when required fields are missing: in _friendlySummary, treat a timer as missing
if first.durationSeconds is null (do not convert null to 0 or return "Timer 0s")
and fall through to the default raw/title case; for alarms only return the
friendly "Alarm ..." when first.datetimeExpressionEnglish or
first.datetimeExpressionOriginal is non-null. Also update the action-emission
logic that constructs actions from ChronoLlmExtraction (the block that currently
emits type: 'timer' / type: 'alarm') to skip creating a timer action when
durationSeconds is null and to skip creating an alarm action when both
datetimeExpressionEnglish and datetimeExpressionOriginal are null, treating
those extractions as failures instead of materializing malformed actions.
In `@zswatch_app/lib/services/ai/voice_note_ai_pipeline.dart`:
- Around line 191-205: The current logic only checks result.actions.first and
treats the whole memo as a note if that first action is note-like, causing
action_type to be omitted even when later actions are actionable; update the
logic that computes isNote/action type/datetime so you first check all items in
result.actions (e.g., using every/any) to determine if all extracted items are
plain notes, and if not select the first non-note action as the actionable item:
leave isNote true only when every action has type 'task' with null startTime and
dueDate, otherwise set actionType and actionDatetime from the first action that
is not a plain note before calling onProcessingComplete(filename,
result.summary, actionType, actionDatetime).
In `@zswatch_app/lib/ui/widgets/ai_debug_widgets.dart`:
- Around line 254-258: The timer branch only reads a?.durationSeconds and thus
drops the scalar path; update the duration expression to fall back to the
extracted scalar (e.g. use a?.durationSeconds ?? extractedDurationSeconds) and
pass that into formatDuration so Duration shows correctly; modify the Duration
line in the intent == 'timer' block (where intent, a, extractedIntent,
extractedTitle and formatDuration are used) to use the fallback.
---
Outside diff comments:
In `@zswatch_app/lib/services/ai/extracted_action_creation_service.dart`:
- Around line 319-325: The Open action is still a no-op for
ExtractedActionType.timer and ExtractedActionType.alarm because they’re mapped
to 'alarm' but _openCreatedCalendarEntryIfSupported() only handles
'calendar_event' and 'calendar_reminder'; modify openCreatedAction() to route
timers/alarms to a proper opener instead of the calendar opener: change the
targetType mapping to produce distinct values (e.g., 'alarm' or 'timer') or
check action.actionType directly, then call a new helper like
_openCreatedAlarmIfSupported()/ _openCreatedTimerIfSupported() (or extend
_openCreatedCalendarEntryIfSupported() to accept 'alarm'/'timer' and handle
platform-specific opening) so tapping Open on created timers/alarms actually
invokes the correct platform-specific open logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 913957d1-b56c-4848-9a97-e2c9407bd28d
📒 Files selected for processing (29)
ai_testbench/.gitignoreai_testbench/README.mdai_testbench/lib/benchmark_main.dartai_testbench/lib/main.dartai_testbench/lib/router_benchmark_main.dartai_testbench/lib/services/model_benchmark_service.dartpackages/chrono_ai_flow/lib/src/models.dartpackages/chrono_ai_flow/lib/src/parser.dartpackages/chrono_ai_flow/lib/src/prompt_template.dartspecs/ai-tasks/alarms-and-timers.mdzswatch_app/android/app/src/main/AndroidManifest.xmlzswatch_app/android/app/src/main/kotlin/dev/zswatch/app/MainActivity.ktzswatch_app/ios/Runner/AppDelegate.swiftzswatch_app/lib/data/database/app_database.dartzswatch_app/lib/data/database/app_database.g.dartzswatch_app/lib/data/database/tables/extracted_actions_table.dartzswatch_app/lib/data/models/extracted_action.dartzswatch_app/lib/data/models/extracted_action.freezed.dartzswatch_app/lib/data/repositories/extracted_action_repository.dartzswatch_app/lib/data/repositories/voice_memo_repository.dartzswatch_app/lib/providers/voice_memo_providers.dartzswatch_app/lib/services/ai/ai_debug_info.dartzswatch_app/lib/services/ai/extracted_action_creation_service.dartzswatch_app/lib/services/ai/llm_service.dartzswatch_app/lib/services/ai/voice_note_ai_pipeline.dartzswatch_app/lib/ui/screens/settings/ai_models_settings_screen.dartzswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dartzswatch_app/lib/ui/widgets/ai_debug_widgets.dartzswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart
| final modelPaths = Directory(modelDir) | ||
| .listSync() | ||
| .whereType<File>() | ||
| .map((f) => f.path) | ||
| .where((p) => p.toLowerCase().endsWith('.gguf')) | ||
| .where( | ||
| (p) => modelFilter == null || p.toLowerCase().contains(modelFilter.toLowerCase())) | ||
| .toList() | ||
| ..sort(); | ||
|
|
||
| if (modelPaths.isEmpty) { | ||
| stdout.writeln('[RouterBench] No .gguf models found'); | ||
| exitCode = 1; | ||
| return; |
There was a problem hiding this comment.
Check that modelDir exists before calling listSync().
If the directory is missing, this throws a FileSystemException and the friendly "No .gguf models found" path never runs.
🛠️ Proposed fix
- final modelPaths = Directory(modelDir)
+ final modelDirectory = Directory(modelDir);
+ if (!modelDirectory.existsSync()) {
+ stdout.writeln('[RouterBench] Model directory not found: $modelDir');
+ exitCode = 1;
+ return;
+ }
+
+ final modelPaths = modelDirectory
.listSync()
.whereType<File>()
.map((f) => f.path)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ai_testbench/lib/router_benchmark_main.dart` around lines 20 - 33, Check that
Directory(modelDir) exists and is a directory before calling listSync() to avoid
a FileSystemException; update the modelPaths construction to first call
Directory(modelDir).existsSync() (or wrap Directory(modelDir).listSync() in a
try/catch) and when the directory is missing or an exception occurs, write a
friendly message via stdout.writeln('[RouterBench] No .gguf models found' or a
specific error) and set exitCode = 1 then return; adjust code around modelPaths,
Directory(modelDir).listSync(), and the early-return branch so the friendly
message runs when the directory is absent or listSync() fails.
| final avgRouterMs = | ||
| routerTimes.fold<int>(0, (s, d) => s + d.inMilliseconds) ~/ | ||
| routerTimes.length; | ||
| stdout.writeln( | ||
| '\n[RouterBench] Router accuracy: $routerCorrect/${cases.length}'); | ||
| stdout.writeln('[RouterBench] Router avg latency: ${avgRouterMs}ms'); |
There was a problem hiding this comment.
The latency summaries are comparing different case sets.
avgRouterMs is computed from all router cases, avgTotalMs from the timer_alarm subset, and avgSingleMs from the first five voice_memo cases. That makes both Avg extract only and Overhead apples-to-oranges metrics.
Also applies to: 199-207, 212-242
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ai_testbench/lib/router_benchmark_main.dart` around lines 130 - 135, The
latency summaries mix different case sets (avgRouterMs uses routerTimes,
avgTotalMs uses the timer_alarm subset, and avgSingleMs uses the first five
voice_memo cases), producing invalid comparisons; update the calculations so
comparisons use the same consistent dataset or compute and label per-subset
metrics explicitly: e.g., create clear collections like routerTimes (all router
cases), timerAlarmTimes (cases where case.type == 'timer_alarm'), voiceMemoTimes
(cases where case.type == 'voice_memo') and compute avgRouterMs,
avgTimerAlarmMs, avgVoiceMemoMs from those collections, then use matching counts
(routerCorrect vs routerCases.length or subsetCorrect vs subset.length) when
printing summaries (references: avgRouterMs, avgTotalMs, avgSingleMs,
routerTimes, routerCorrect, cases, timer_alarm, voice_memo).
| for (final tc in timerCases) { | ||
| final sw = Stopwatch()..start(); | ||
|
|
||
| // Stage 1: Router | ||
| llm.maxTokens = 32; | ||
| final routerPrompt = ChronoPromptTemplate.render( | ||
| ChronoPromptTemplate.routerTemplate, | ||
| transcript: tc.transcript, | ||
| now: referenceTime, | ||
| ); | ||
| final routerResult = await llm.generate(routerPrompt).timeout( | ||
| const Duration(seconds: 30), | ||
| onTimeout: () => const InferenceResult( | ||
| output: '{"route":"timeout"}', elapsed: Duration(seconds: 30)), | ||
| ); | ||
| final routerMs = routerResult.elapsed.inMilliseconds; | ||
|
|
||
| // Stage 2: Timer/alarm extraction | ||
| llm.maxTokens = 384; | ||
| final extractPrompt = ChronoPromptTemplate.render( | ||
| ChronoPromptTemplate.timerAlarmTemplate, | ||
| transcript: tc.transcript, | ||
| now: referenceTime, | ||
| ); | ||
| final extractResult = await llm.generate(extractPrompt).timeout( | ||
| const Duration(seconds: 60), | ||
| onTimeout: () => const InferenceResult( | ||
| output: '[]', elapsed: Duration(seconds: 60)), | ||
| ); | ||
| final extractMs = extractResult.elapsed.inMilliseconds; | ||
|
|
||
| sw.stop(); | ||
| totalTimes.add(sw.elapsed); | ||
|
|
||
| final parseResult = parser.parse(extractResult.output); | ||
| final first = parseResult.extractions.isNotEmpty | ||
| ? parseResult.extractions.first | ||
| : null; | ||
| final intentOk = first != null && | ||
| (first.intent == 'timer' || first.intent == 'alarm'); | ||
| if (intentOk) extractionCorrect++; | ||
|
|
||
| stdout.writeln( | ||
| ' ${intentOk ? "OK" : "FAIL"} intent=${first?.intent ?? "null"} ' | ||
| 'dur=${first?.durationSeconds ?? "null"} ' | ||
| 'router=${routerMs}ms extract=${extractMs}ms ' | ||
| 'total=${sw.elapsedMilliseconds}ms "${tc.transcript}"'); |
There was a problem hiding this comment.
Stage 2 isn't measuring the actual two-stage pipeline.
routerResult is timed but never used to decide whether extraction should run, so router misclassifications do not count as pipeline failures. Success is also only first.intent == timer || alarm, which lets the multi-item timer+alarm case pass even if the second extraction is missing or the parsed duration/time is wrong.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ai_testbench/lib/router_benchmark_main.dart` around lines 150 - 196, The
router result is measured but not used to gate extraction, so misrouted cases
are counted as successes; also success only checks first.intent which misses
multi-item or wrong-duration failures. Change the loop to parse
routerResult.output (e.g., inspect routerResult.output JSON or
ChronoPromptTemplate router response) and only run extraction when the router
route indicates timer/alarm; if router indicates a different route mark the case
failed immediately and count router time. After running parser.parse on
extractResult.output, make intentOk require that the router-declared route
matches the parsed extractions and that at least one extraction has intent ==
'timer' or 'alarm' with a non-null duration/time (handling multi-item by
scanning all parseResult.extractions). Ensure timing (routerMs and extractMs)
reflect whether extraction actually ran and that pipeline failures include
router misclassification or missing/invalid extractions.
| /// Prompt template to use for extraction. | ||
| String promptTemplate = ChronoPromptTemplate.defaultTemplate; |
There was a problem hiding this comment.
Bind timer/alarm benchmark runs to the timer/alarm prompt.
timerAlarmCases can now be selected independently, but runForModels() still renders whatever promptTemplate happens to be set on the service. The default runner in ai_testbench/lib/benchmark_main.dart:176-214 constructs ModelBenchmarkService() without overriding it, while the dedicated timer/alarm path in ai_testbench/lib/router_benchmark_main.dart:1-25 uses ChronoPromptTemplate.timerAlarmTemplate. As written, headless timer runs can silently benchmark the new cases against the wrong prompt.
Based on learnings, in ai_testbench, test shared chrono_ai_flow prompts and schemas.
Also applies to: 1040-1043, 1088-1093
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ai_testbench/lib/services/model_benchmark_service.dart` around lines 214 -
215, The service uses the mutable field promptTemplate
(ChronoPromptTemplate.defaultTemplate) independently of timerAlarmCases, so runs
that select timerAlarmCases still use whatever promptTemplate is set; update
ModelBenchmarkService (the constructor or runForModels) so that when
timerAlarmCases is enabled it explicitly sets promptTemplate =
ChronoPromptTemplate.timerAlarmTemplate (or otherwise selects that template)
before rendering prompts in runForModels; ensure the same binding is applied in
any code paths that construct ModelBenchmarkService (e.g., the default runner vs
router benchmark path) so timer/alarm benchmarks always use
ChronoPromptTemplate.timerAlarmTemplate instead of the global/default
promptTemplate.
| BenchmarkCase.single( | ||
| name: 'en_alarm_morning', | ||
| transcript: 'Set an alarm for 7:30 AM', | ||
| expectedIntent: 'alarm', | ||
| expectTime: true, | ||
| expectedDateTime: DateTime(2026, 3, 11, 7, 30), | ||
| ), |
There was a problem hiding this comment.
Two alarm fixtures are pinned to a past timestamp.
With referenceTime = DateTime(2026, 3, 11, 10, 15), 'Set an alarm for 7:30 AM' and 'Alarm klockan halv 8, dags att gå' should resolve to March 12, 2026 7:30 AM, not earlier on March 11, 2026. Leaving these on March 11 will mark sensible next-day outputs as failures.
🐛 Proposed fix
- expectedDateTime: DateTime(2026, 3, 11, 7, 30),
+ expectedDateTime: DateTime(2026, 3, 12, 7, 30),
...
- expectedDateTime: DateTime(2026, 3, 11, 7, 30),
+ expectedDateTime: DateTime(2026, 3, 12, 7, 30),Also applies to: 946-953
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ai_testbench/lib/services/model_benchmark_service.dart` around lines 910 -
916, The two alarm fixtures in the BenchmarkCase.single entries (e.g., the case
with name 'en_alarm_morning' and the Swedish alarm case around lines 946-953)
use expectedDateTime set to March 11, 2026 but with referenceTime =
DateTime(2026,3,11,10,15) they should resolve to the next day; update the
expectedDateTime for those BenchmarkCase.single instances to DateTime(2026, 3,
12, 7, 30) so the expected intent/time matches the sensible next-day resolution.
| static String _friendlySummary(ChronoLlmExtraction first, String raw) { | ||
| if (first.intent == 'timer') { | ||
| final d = first.durationSeconds ?? 0; | ||
| final h = d ~/ 3600; | ||
| final m = (d % 3600) ~/ 60; | ||
| final s = d % 60; | ||
| final parts = <String>[ | ||
| if (h > 0) '${h}h', | ||
| if (m > 0) '${m}m', | ||
| if (s > 0 || (h == 0 && m == 0)) '${s}s', | ||
| ]; | ||
| final dur = parts.join(' '); | ||
| return first.title.isNotEmpty ? 'Timer $dur — ${first.title}' : 'Timer $dur'; | ||
| } | ||
| if (first.intent == 'alarm') { | ||
| final expr = first.datetimeExpressionEnglish ?? first.datetimeExpressionOriginal; | ||
| if (expr != null) { | ||
| return first.title.isNotEmpty ? 'Alarm $expr — ${first.title}' : 'Alarm $expr'; | ||
| } | ||
| return first.title.isNotEmpty ? 'Alarm — ${first.title}' : 'Alarm'; | ||
| } | ||
| return first.title.isNotEmpty ? first.title : raw.trim(); | ||
| } |
There was a problem hiding this comment.
Don't materialize timer/alarm actions when their required fields are missing.
Lines 1497-1507 turn a missing duration into Timer 0s, and Lines 1551-1598 still emit type: 'timer' / type: 'alarm' actions when durationSeconds is null or time resolution failed. That malformed model output is then treated as a valid native action instead of being rejected here as an extraction failure.
Also applies to: 1551-1598
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@zswatch_app/lib/services/ai/llm_service.dart` around lines 1495 - 1517,
Change _friendlySummary and the code that materializes native actions from
ChronoLlmExtraction so we don't create timer/alarm outputs when required fields
are missing: in _friendlySummary, treat a timer as missing if
first.durationSeconds is null (do not convert null to 0 or return "Timer 0s")
and fall through to the default raw/title case; for alarms only return the
friendly "Alarm ..." when first.datetimeExpressionEnglish or
first.datetimeExpressionOriginal is non-null. Also update the action-emission
logic that constructs actions from ChronoLlmExtraction (the block that currently
emits type: 'timer' / type: 'alarm') to skip creating a timer action when
durationSeconds is null and to skip creating an alarm action when both
datetimeExpressionEnglish and datetimeExpressionOriginal are null, treating
those extractions as failures instead of materializing malformed actions.
| final intent = a?.intent ?? extractedIntent; | ||
| if (intent == 'timer') { | ||
| return 'Intent: timer\n' | ||
| 'Title: ${show(a?.title ?? extractedTitle)}\n' | ||
| 'Duration: ${formatDuration(a?.durationSeconds)}'; |
There was a problem hiding this comment.
Timer duration is still dropped on the direct-field path.
This helper still supports callers that pass the scalar chrono fields without extractedActions, but the timer branch only reads a?.durationSeconds. In that case the debug sheet always shows Duration: null.
💡 Suggested fix
String aiFormatChronoDetails({
String? extractedIntent,
String? extractedTitle,
String? datetimeExpressionOriginal,
String? datetimeExpressionEnglish,
String? resolvedDateTime,
String? resolverMethod,
+ int? durationSeconds,
List<ActionChronoDebug> extractedActions = const [],
}) {
@@
if (intent == 'timer') {
return 'Intent: timer\n'
'Title: ${show(a?.title ?? extractedTitle)}\n'
- 'Duration: ${formatDuration(a?.durationSeconds)}';
+ 'Duration: ${formatDuration(a?.durationSeconds ?? durationSeconds)}';
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@zswatch_app/lib/ui/widgets/ai_debug_widgets.dart` around lines 254 - 258, The
timer branch only reads a?.durationSeconds and thus drops the scalar path;
update the duration expression to fall back to the extracted scalar (e.g. use
a?.durationSeconds ?? extractedDurationSeconds) and pass that into
formatDuration so Duration shows correctly; modify the Duration line in the
intent == 'timer' block (where intent, a, extractedIntent, extractedTitle and
formatDuration are used) to use the fallback.
Skip LLM inference for blank audio, silence markers, and pure punctuation. The router now supports a "none" route and the pipeline marks empty results as failed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New alarms & timers screen with routing, updated prompt template for better LLM routing, benchmark service adjustments, and minor voice memo UI refinements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (3)
zswatch_app/test/no_speech_filter_test.dart (1)
50-52: Consider adding an empty string test case.While
processMemocheckstranscript.trim().isEmptybefore callingisNoSpeechTranscript, the static method itself would returntruefor empty strings (sincestripped.isEmptywould be true). Adding an explicit test for''documents this behavior.💡 Suggested test addition
test('detects empty string', () { expect(VoiceNoteAiPipeline.isNoSpeechTranscript(''), isTrue); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@zswatch_app/test/no_speech_filter_test.dart` around lines 50 - 52, Add an explicit unit test for the empty-string case so isNoSpeechTranscript behavior is documented and covered: add a new test that calls VoiceNoteAiPipeline.isNoSpeechTranscript('') and expects true. This complements the existing test 'detects whitespace-only' and aligns with the guard in processMemo that checks transcript.trim().isEmpty.zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart (1)
339-380: Duration formatting logic is duplicated fromLlmService._friendlySummary.The timer duration formatting (Lines 350-364) mirrors the logic in
llm_service.dartLines 1517-1525. If the format changes, both locations must be updated.Consider extracting a shared
formatDuration(int seconds)helper into a common utility, or at minimum add a comment noting the duplication to keep them in sync.♻️ Proposed shared helper
// In a shared utils file: String formatTimerDuration(int seconds) { final h = seconds ~/ 3600; final m = (seconds % 3600) ~/ 60; final s = seconds % 60; final parts = <String>[ if (h > 0) '${h}h', if (m > 0) '${m}m', if (s > 0 || (h == 0 && m == 0)) '${s}s', ]; return parts.join(' '); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart` around lines 339 - 380, The timer duration formatting in cleanSummary duplicates logic from LlmService._friendlySummary; extract the shared logic into a single utility (e.g., formatTimerDuration(int seconds)) and replace the duration computation in cleanSummary with a call to that helper (or, if you can't extract now, add a clear TODO comment in cleanSummary referencing LlmService._friendlySummary to keep them in sync). Update references so cleanSummary uses the new utility and ensure LlmService._friendlySummary calls the same helper to avoid future drift.zswatch_app/lib/data/database/app_database.g.dart (1)
4163-4164: Refresh theactionTypedocs to match timer/alarm support.With
durationSecondsadded here, the adjacentactionTypecomment at Line 4130 still says the type is limited totask,calendar_event, andreminder. Please fix the source comment before regenerating so the generated API docs reflect the new action surface.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@zswatch_app/lib/data/database/app_database.g.dart` around lines 4163 - 4164, Update the source doc comment for the actionType field (adjacent to durationSeconds and the actionType declaration) to include the new supported action kinds "timer" and "alarm" in addition to "task", "calendar_event", and "reminder", then regenerate the g.dart so the generated API docs reflect timer/alarm support; target the comment above the actionType declaration (the actionType field/property) rather than the generated file directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@zswatch_app/lib/data/database/app_database.g.dart`:
- Around line 4163-4164: Update the source doc comment for the actionType field
(adjacent to durationSeconds and the actionType declaration) to include the new
supported action kinds "timer" and "alarm" in addition to "task",
"calendar_event", and "reminder", then regenerate the g.dart so the generated
API docs reflect timer/alarm support; target the comment above the actionType
declaration (the actionType field/property) rather than the generated file
directly.
In `@zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart`:
- Around line 339-380: The timer duration formatting in cleanSummary duplicates
logic from LlmService._friendlySummary; extract the shared logic into a single
utility (e.g., formatTimerDuration(int seconds)) and replace the duration
computation in cleanSummary with a call to that helper (or, if you can't extract
now, add a clear TODO comment in cleanSummary referencing
LlmService._friendlySummary to keep them in sync). Update references so
cleanSummary uses the new utility and ensure LlmService._friendlySummary calls
the same helper to avoid future drift.
In `@zswatch_app/test/no_speech_filter_test.dart`:
- Around line 50-52: Add an explicit unit test for the empty-string case so
isNoSpeechTranscript behavior is documented and covered: add a new test that
calls VoiceNoteAiPipeline.isNoSpeechTranscript('') and expects true. This
complements the existing test 'detects whitespace-only' and aligns with the
guard in processMemo that checks transcript.trim().isEmpty.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 139aa28f-aa58-493b-9187-cd88af1704f3
📒 Files selected for processing (14)
zswatch_app/lib/data/database/app_database.dartzswatch_app/lib/data/database/app_database.g.dartzswatch_app/lib/data/database/tables/voice_memos_table.dartzswatch_app/lib/data/models/voice_memo.dartzswatch_app/lib/data/models/voice_memo.freezed.dartzswatch_app/lib/data/repositories/extracted_action_repository.dartzswatch_app/lib/data/repositories/voice_memo_repository.dartzswatch_app/lib/providers/ai_providers.dartzswatch_app/lib/providers/voice_memo_providers.dartzswatch_app/lib/services/ai/llm_service.dartzswatch_app/lib/services/ai/voice_note_ai_pipeline.dartzswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dartzswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dartzswatch_app/test/no_speech_filter_test.dart
🚧 Files skipped from review as they are similar to previous changes (2)
- zswatch_app/lib/data/database/app_database.dart
- zswatch_app/lib/data/repositories/voice_memo_repository.dart
… mixed route - Fix DB migration v1→v4+ crash: guard addColumn with from >= 2 so freshly-created tables don't get duplicate columns - Fix "Timer 0s" summary when durationSeconds is null - Skip permission request for timer/alarm on iOS (uses URL schemes) - Route "mixed" router result to timerAlarmTemplate instead of dropping timer/alarm intents via the voice_memo-only compact template - Scan all actions (not just first) for watch notification routing - Reject incomplete timer/alarm in parser (timer needs duration, alarm needs datetime) - Early-return in openCreatedAction for timer/alarm (no calendar entry) - Format all Dart code for CI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
ai_testbench/lib/services/model_benchmark_service.dart (1)
1355-1356: Consider adding a comment explaining the redundancy.The parser already normalizes
countdown→timer(perparser.dart:180-200), so this fuzzy match is defense-in-depth. A brief comment would clarify this is intentional robustness, not oversight.// Allow note <-> task fuzzy match (no time = note) if ({g, e}.containsAll({'note', 'task'})) return true; - // Allow countdown <-> timer fuzzy match + // Allow countdown <-> timer fuzzy match (parser normalizes, but accept both for robustness) if ({g, e}.containsAll({'countdown', 'timer'})) return true;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ai_testbench/lib/services/model_benchmark_service.dart` around lines 1355 - 1356, Add a short clarifying comment immediately above the defensive fuzzy-match line that uses {g, e}.containsAll({'countdown', 'timer'}) stating that this check is redundant by design because the parser already normalizes "countdown" → "timer" (see parser.dart normalization), and that the extra check is retained as defense-in-depth to tolerate unexpected upstream inputs or future parser changes; keep the comment concise and reference both the normalization and that this is intentional robustness, not an oversight.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@zswatch_app/lib/ui/screens/voice_memos/alarms_timers_screen.dart`:
- Around line 252-275: The action row currently only disables individual
buttons; compute a single busy flag (e.g. _isBusy = _isCreating || _isDismissing
|| _isDeleting || _isOpening) and use it to disable or block the whole row when
any async operation is in flight: wrap the buttons block (the
FilledButton/OutlinedButton group that checks isPending and the always-available
Delete/Open buttons) with an IgnorePointer or AbsorbPointer keyed to _isBusy
and/or set each button's onPressed to null when _isBusy, so no other action
(_createAction, _dismissAction, _deleteAction, _openCreatedAction) can be
invoked while any of the async flags are true; apply the same pattern to the
other affected ranges (303-395) mentioned in the comment.
- Around line 103-116: The list item widgets use _AlarmTimerCard without keys,
which lets Flutter reuse internal state when actions move between pending and
completed; give each _AlarmTimerCard a stable Key (e.g., pass Key:
ValueKey(action.id) or ValueKey(action.uuid) or ValueKey(action) if the action
has a reliable unique field) when constructing the card in both the pending and
completed loops (the usages of _AlarmTimerCard in the pending/completed
builders, including the other occurrence noted), so each action keeps its own
state when the stream reorders items.
In `@zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart`:
- Around line 364-404: The cleanSummary function should parse JSON structurally
instead of using fragile regexes: in cleanSummary(String summary) try to
jsonDecode the trimmed string (import dart:convert) inside a try/catch, handle
resulting Map or List (if List, use first element if it's a Map), then read keys
like "title", "intent", "duration_seconds" and "datetime_expression_english"
directly from the Map to build the same formatted outputs (timer: compute h/m/s
from duration_seconds; alarm: use datetime_expression_english; fallback to
title), and return trimmed if decoding fails or the decoded value doesn't match
expected types; keep existing return formats and fallback behavior.
---
Nitpick comments:
In `@ai_testbench/lib/services/model_benchmark_service.dart`:
- Around line 1355-1356: Add a short clarifying comment immediately above the
defensive fuzzy-match line that uses {g, e}.containsAll({'countdown', 'timer'})
stating that this check is redundant by design because the parser already
normalizes "countdown" → "timer" (see parser.dart normalization), and that the
extra check is retained as defense-in-depth to tolerate unexpected upstream
inputs or future parser changes; keep the comment concise and reference both the
normalization and that this is intentional robustness, not an oversight.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: db2df3a1-10f2-4bdc-91f4-21f1cab2de9b
📒 Files selected for processing (6)
ai_testbench/lib/services/model_benchmark_service.dartpackages/chrono_ai_flow/lib/src/prompt_template.dartzswatch_app/lib/ui/navigation/app_router.dartzswatch_app/lib/ui/screens/voice_memos/alarms_timers_screen.dartzswatch_app/lib/ui/screens/voice_memos/voice_memos_screen.dartzswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/chrono_ai_flow/lib/src/prompt_template.dart
| if (isPending) ...[ | ||
| FilledButton( | ||
| style: _compactFilledStyle(), | ||
| onPressed: _isCreating ? null : _createAction, | ||
| child: Text(_isCreating ? 'Setting...' : 'Set'), | ||
| ), | ||
| OutlinedButton( | ||
| style: _compactWarnStyle(), | ||
| onPressed: _isDismissing ? null : _dismissAction, | ||
| child: Text(_isDismissing ? 'Dismissing...' : 'Dismiss'), | ||
| ), | ||
| ], | ||
| if (action.created && action.platformTargetId != null) | ||
| OutlinedButton( | ||
| style: _compactOutlinedStyle(), | ||
| onPressed: _isOpening ? null : _openCreatedAction, | ||
| child: const Text('Open'), | ||
| ), | ||
| // Delete is always available | ||
| OutlinedButton( | ||
| style: _compactDangerStyle(), | ||
| onPressed: _isDeleting ? null : _deleteAction, | ||
| child: Text(_isDeleting ? 'Deleting...' : 'Delete'), | ||
| ), |
There was a problem hiding this comment.
Disable the whole action row while one async operation is in flight.
Set, Dismiss, Delete, and Open only disable themselves. That still lets users fire Delete or Dismiss while createAction() is awaiting the OS call, which can leave a timer/alarm created but no longer tracked in the database.
🛡️ Suggested fix
`@override`
Widget build(BuildContext context) {
final isPending = !action.created && !action.dismissed;
+ final isBusy =
+ _isCreating || _isDismissing || _isDeleting || _isOpening;
...
- onPressed: _isCreating ? null : _createAction,
+ onPressed: isBusy ? null : _createAction,
...
- onPressed: _isDismissing ? null : _dismissAction,
+ onPressed: isBusy ? null : _dismissAction,
...
- onPressed: _isOpening ? null : _openCreatedAction,
+ onPressed: isBusy ? null : _openCreatedAction,
...
- onPressed: _isDeleting ? null : _deleteAction,
+ onPressed: isBusy ? null : _deleteAction,Also applies to: 303-395
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@zswatch_app/lib/ui/screens/voice_memos/alarms_timers_screen.dart` around
lines 252 - 275, The action row currently only disables individual buttons;
compute a single busy flag (e.g. _isBusy = _isCreating || _isDismissing ||
_isDeleting || _isOpening) and use it to disable or block the whole row when any
async operation is in flight: wrap the buttons block (the
FilledButton/OutlinedButton group that checks isPending and the always-available
Delete/Open buttons) with an IgnorePointer or AbsorbPointer keyed to _isBusy
and/or set each button's onPressed to null when _isBusy, so no other action
(_createAction, _dismissAction, _deleteAction, _openCreatedAction) can be
invoked while any of the async flags are true; apply the same pattern to the
other affected ranges (303-395) mentioned in the comment.
| /// Clean up AI summary text that may contain raw JSON from a previous run. | ||
| String cleanSummary(String summary) { | ||
| final trimmed = summary.trim(); | ||
| if (trimmed.startsWith('[') || trimmed.startsWith('{')) { | ||
| // Attempt to extract a readable title from JSON | ||
| final titleMatch = RegExp(r'"title"\s*:\s*"([^"]*)"').firstMatch(trimmed); | ||
| final intentMatch = RegExp(r'"intent"\s*:\s*"([^"]*)"').firstMatch(trimmed); | ||
| final intent = intentMatch?.group(1); | ||
| final title = titleMatch?.group(1); | ||
|
|
||
| if (intent == 'timer') { | ||
| final durMatch = | ||
| RegExp(r'"duration_seconds"\s*:\s*(\d+)').firstMatch(trimmed); | ||
| final d = int.tryParse(durMatch?.group(1) ?? '') ?? 0; | ||
| final h = d ~/ 3600; | ||
| final m = (d % 3600) ~/ 60; | ||
| final s = d % 60; | ||
| final parts = <String>[ | ||
| if (h > 0) '${h}h', | ||
| if (m > 0) '${m}m', | ||
| if (s > 0 || (h == 0 && m == 0)) '${s}s', | ||
| ]; | ||
| final dur = parts.join(' '); | ||
| return (title != null && title.isNotEmpty) | ||
| ? 'Timer $dur — $title' | ||
| : 'Timer $dur'; | ||
| } | ||
| if (intent == 'alarm') { | ||
| final expr = RegExp(r'"datetime_expression_english"\s*:\s*"([^"]*)"') | ||
| .firstMatch(trimmed) | ||
| ?.group(1); | ||
| if (expr != null && expr.isNotEmpty) { | ||
| return (title != null && title.isNotEmpty) | ||
| ? 'Alarm $expr — $title' | ||
| : 'Alarm $expr'; | ||
| } | ||
| return (title != null && title.isNotEmpty) ? 'Alarm — $title' : 'Alarm'; | ||
| } | ||
| if (title != null && title.isNotEmpty) return title; | ||
| } | ||
| return trimmed; |
There was a problem hiding this comment.
Use structured JSON parsing in cleanSummary().
The current regexes stop at the first raw ", so escaped fields like Wake up \"early\" still render truncated timer/alarm labels here. jsonDecode would preserve the content and let this function handle real JSON instead of regex-scraping it.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@zswatch_app/lib/ui/widgets/voice_memos/memo_list_item.dart` around lines 364
- 404, The cleanSummary function should parse JSON structurally instead of using
fragile regexes: in cleanSummary(String summary) try to jsonDecode the trimmed
string (import dart:convert) inside a try/catch, handle resulting Map or List
(if List, use first element if it's a Map), then read keys like "title",
"intent", "duration_seconds" and "datetime_expression_english" directly from the
Map to build the same formatted outputs (timer: compute h/m/s from
duration_seconds; alarm: use datetime_expression_english; fallback to title),
and return trimmed if decoding fails or the decoded value doesn't match expected
types; keep existing return formats and fallback behavior.
…rder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… mixed route - Fix DB migration v1→v4+ crash: guard addColumn with from >= 2 so freshly-created tables don't get duplicate columns - Fix "Timer 0s" summary when durationSeconds is null - Skip permission request for timer/alarm on iOS (uses URL schemes) - Route "mixed" router result to timerAlarmTemplate instead of dropping timer/alarm intents via the voice_memo-only compact template - Scan all actions (not just first) for watch notification routing - Reject incomplete timer/alarm in parser (timer needs duration, alarm needs datetime) - Early-return in openCreatedAction for timer/alarm (no calendar entry) - Format all Dart code for CI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement timer/alarm creation from voice memos using a two-stage LLM routing approach: a lightweight router prompt classifies transcripts as timer_alarm/voice_memo/mixed, then routes to the appropriate extraction prompt. Timers and alarms are created via Android AlarmClock intents (with EXTRA_SKIP_UI for silent background operation) and iOS Clock app URL schemes.
Key changes:
Summary by CodeRabbit
New Features
Bug Fixes
Documentation