feat: free-RAM hard gate before heavy model load#4
Conversation
Accumulate STT until listening ends so early finalResult no longer sends a partial message. Add open-app/alarm intent shortcuts and restore tool capability text when native FC is disabled on Android Gemma 4. Co-authored-by: Cursor <cursoragent@cursor.com>
Normalize umlauts so geöffnet is not treated as an open intent, gate device-tool capability text to Android, and align beginner voice busy checks with the main chat screen. Co-authored-by: Cursor <cursoragent@cursor.com>
Stop writing nova_export files into the app-private documents path. Build export content in memory and open the existing Android share chooser so users can save to Files, Drive, or other apps. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Reviewer's GuideImplements an Android-only free-RAM hard gate before cold-loading heavy models, adds deterministic open-app handling and device-tool capability prompts, refines voice dictation behavior, switches chat export to in-memory text/JSON with a new sharing flow, and broadens German-language alarm and open-app parsing, with tests for the new behaviors. Sequence diagram for Android free-RAM hard gate before heavy model loadsequenceDiagram
participant AssistantScreen
participant ModelOrchestrator
participant PlatformAdaptationService
participant MemoryDiagnosticsService
AssistantScreen->>ModelOrchestrator: getOrLoadModel(model)
ModelOrchestrator->>PlatformAdaptationService: checkCanLoadModel(model)
PlatformAdaptationService->>MemoryDiagnosticsService: readAvailableMemMb()
MemoryDiagnosticsService-->>PlatformAdaptationService: availMemMb
PlatformAdaptationService-->>ModelOrchestrator: freeRamGateMessage or null
alt RAM too low
ModelOrchestrator-->>AssistantScreen: throw ModelException(message)
else RAM sufficient or gate not applicable
ModelOrchestrator-->>AssistantScreen: proceed to load/switch model
end
Sequence diagram for deterministic open-app intent handlingsequenceDiagram
actor User
participant AssistantScreen
participant ModelOrchestrator
participant OpenAppIntentParser
participant ToolExecutorService
User->>AssistantScreen: enter query
AssistantScreen->>ModelOrchestrator: sendMessage(query)
ModelOrchestrator->>OpenAppIntentParser: tryParsePackage(query)
OpenAppIntentParser-->>ModelOrchestrator: openPackage or null
alt openPackage != null
ModelOrchestrator->>ToolExecutorService: openApp(openPackage)
ToolExecutorService-->>ModelOrchestrator: result{success,error}
ModelOrchestrator-->>AssistantScreen: InferenceResult with open_app toolCalls
else no open-app intent
ModelOrchestrator-->>AssistantScreen: normal inference flow
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Block cold loads of Gemma 4 / mid-size models on Android when ActivityManager reports insufficient free RAM, with a clear fallback suggestion to smaller models. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Code Review
This pull request introduces several key enhancements, including a free-RAM hard gate before loading heavy models on Android, a deterministic 'open app' shortcut with German intent parsing, German language support for alarm parsing, direct conversation export via a share sheet (avoiding local file writes), and improved voice input handling with live previews. The review feedback highlights opportunities to improve robustness and efficiency, such as returning null instead of stale cached memory values on failure, making the German 'mach auf' regex more flexible, wrapping speech stop calls in try-catch blocks to prevent stuck UI states, updating text controllers in a single transaction to avoid cursor glitches, and optimizing the app intent parser's alias matching logic.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| } | ||
| } | ||
|
|
||
| return _lastAvailMemMb; |
There was a problem hiding this comment.
If the platform channel call fails or throws a PlatformException, returning _lastAvailMemMb will return a stale cached value from a previous successful reading. Since system RAM changes dynamically, using a stale value could lead to false positives (incorrectly blocking the user) or false negatives (crashing due to LMK). It is safer to return null on failure to ensure the gate is soft-skipped as intended.
| return _lastAvailMemMb; | |
| return null; |
| static final RegExp _machAufIntent = RegExp( | ||
| r'\bmach(?:en)?\s+\S+\s+auf\b', | ||
| caseSensitive: false, | ||
| ); |
There was a problem hiding this comment.
The _machAufIntent regex uses \S+ which matches exactly one non-whitespace word. In German, it is extremely common to have multiple words or articles between "mach" and "auf" (e.g., "mach die Kamera auf" or "mach bitte YouTube auf"). Using [^.!?]+? instead of \S+ makes the intent parser much more robust while still preventing matches from spanning across multiple sentences.
| static final RegExp _machAufIntent = RegExp( | |
| r'\bmach(?:en)?\s+\S+\s+auf\b', | |
| caseSensitive: false, | |
| ); | |
| static final RegExp _machAufIntent = RegExp( | |
| r'\bmach(?:en)?\s+[^.!?]+?\s+auf\b', | |
| caseSensitive: false, | |
| ); |
| Future<void> _finishListening() async { | ||
| if (_flushing) return; | ||
| _flushing = true; | ||
| try { | ||
| await _speech.stop(); | ||
| if (mounted) setState(() => _isListening = false); | ||
| if (_lastWords.isNotEmpty) { | ||
| widget.onTranscription(_lastWords); | ||
| _lastWords = ''; | ||
| final transcript = _lastWords.trim(); | ||
| _lastWords = ''; | ||
| if (transcript.isNotEmpty) { | ||
| widget.onTranscription(transcript); | ||
| } | ||
| } else { | ||
| await _startListening(); | ||
| } finally { | ||
| _flushing = false; | ||
| } | ||
| } |
There was a problem hiding this comment.
If _speech.stop() throws an exception (e.g., if the speech engine is in an unexpected state), the setState(() => _isListening = false) call will be skipped. This will leave the UI permanently stuck in the "listening" state. Wrapping _speech.stop() in a try-catch block ensures that the UI state is always cleaned up and reset gracefully.
Future<void> _finishListening() async {
if (_flushing) return;
_flushing = true;
try {
try {
await _speech.stop();
} catch (e) {
debugPrint('Error stopping speech recognition: $e');
}
if (mounted) setState(() => _isListening = false);
final transcript = _lastWords.trim();
_lastWords = '';
if (transcript.isNotEmpty) {
widget.onTranscription(transcript);
}
} finally {
_flushing = false;
}
}| onPartial: (partial) { | ||
| _inputController.text = partial; | ||
| _inputController.selection = TextSelection.collapsed( | ||
| offset: partial.length, | ||
| ); | ||
| }, |
There was a problem hiding this comment.
Updating the text and selection separately triggers the controller's listeners twice, which can cause cursor jump glitches or redundant UI rebuilds. It is more idiomatic in Flutter to update both text and selection in a single transaction by setting the value property of the TextEditingController.
onPartial: (partial) {
_inputController.value = TextEditingValue(
text: partial,
selection: TextSelection.collapsed(
offset: partial.length,
),
);
},| onPartial: (partial) { | ||
| _inputController.text = partial; | ||
| _inputController.selection = TextSelection.collapsed( | ||
| offset: partial.length, | ||
| ); | ||
| }, |
There was a problem hiding this comment.
Updating the text and selection separately triggers the controller's listeners twice, which can cause cursor jump glitches or redundant UI rebuilds. It is more idiomatic in Flutter to update both text and selection in a single transaction by setting the value property of the TextEditingController.
onPartial: (partial) {
_inputController.value = TextEditingValue(
text: partial,
selection: TextSelection.collapsed(
offset: partial.length,
),
);
},| static String? tryParsePackage(String query) { | ||
| if (!_hasOpenIntent(query)) return null; | ||
|
|
||
| final lower = query.toLowerCase(); | ||
| // Prefer longer aliases first (e.g. "google maps" before "maps"). | ||
| final aliases = knownApps.keys.toList() | ||
| ..sort((a, b) => b.length.compareTo(a.length)); | ||
| for (final alias in aliases) { | ||
| if (lower.contains(alias)) { | ||
| return knownApps[alias]; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } |
There was a problem hiding this comment.
Sorting the aliases list on every call to tryParsePackage is inefficient. Additionally, simply checking lower.contains(alias) in descending order of length can return the wrong app if multiple apps are mentioned or if there are substring overlaps. A more robust and efficient approach is to find all matching aliases in a single pass, picking the one that appears earliest in the query, and using length as a tie-breaker.
static String? tryParsePackage(String query) {
if (!_hasOpenIntent(query)) return null;
final lower = query.toLowerCase();
String? bestAlias;
int bestIndex = -1;
for (final alias in knownApps.keys) {
final index = lower.indexOf(alias);
if (index != -1) {
if (bestIndex == -1 || index < bestIndex) {
bestIndex = index;
bestAlias = alias;
} else if (index == bestIndex) {
if (bestAlias == null || alias.length > bestAlias.length) {
bestAlias = alias;
}
}
}
}
return bestAlias != null ? knownApps[bestAlias] : null;
}There was a problem hiding this comment.
Hey - I've found 2 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="lib/services/memory_diagnostics_service.dart" line_range="26-35" />
<code_context>
+ int? get lastAvailMemMb => _lastAvailMemMb;
+
+ /// Best-effort free system RAM in MB (Android ActivityManager).
+ Future<int?> readAvailableMemMb() async {
+ if (!kIsWeb && Platform.isAndroid) {
+ try {
+ final result = await _channel.invokeMethod<Map<Object?, Object?>>(
+ 'getProcessMemory',
+ );
+ if (result != null) {
+ _lastPssKb = result['pssKb'] as int?;
+ _lastRssKb = result['rssKb'] as int?;
+ final avail = result['availMemMb'];
+ if (avail is int) {
+ _lastAvailMemMb = avail;
+
+ return avail;
+ }
+ if (avail is num) {
+ _lastAvailMemMb = avail.round();
+
+ return _lastAvailMemMb;
+ }
+ }
</code_context>
<issue_to_address>
**suggestion:** Factor out shared parsing of `availMemMb` to reduce duplication and keep fields in sync.
Both methods currently parse `availMemMb` and update `_lastAvailMemMb` slightly differently. A small private helper that takes the result map and sets `_lastPssKb`, `_lastRssKb`, and `_lastAvailMemMb` in one place would remove duplication and guarantee these fields stay consistent across both code paths.
Suggested implementation:
```
int? _lastRssKb;
int? _lastAvailMemMb;
int? get lastPssMb =>
_lastPssKb != null ? (_lastPssKb! / 1024).round() : null;
int? get lastRssMb =>
_lastRssKb != null ? (_lastRssKb! / 1024).round() : null;
int? get lastAvailMemMb => _lastAvailMemMb;
/// Parses process memory info from a platform channel result map and updates
/// [_lastPssKb], [_lastRssKb] and [_lastAvailMemMb] consistently.
///
/// Returns the parsed available memory in MB, or null if it is not present.
int? _updateProcessMemoryFromResult(Map<Object?, Object?> result) {
_lastPssKb = result['pssKb'] as int?;
_lastRssKb = result['rssKb'] as int?;
final avail = result['availMemMb'];
if (avail is int) {
_lastAvailMemMb = avail;
} else if (avail is num) {
_lastAvailMemMb = avail.round();
} else {
_lastAvailMemMb = null;
}
return _lastAvailMemMb;
}
```
```
if (result != null) {
return _updateProcessMemoryFromResult(result);
}
```
There is at least one other method in this service that also parses `pssKb`, `rssKb`, and `availMemMb` from the platform channel result. To fully apply the refactor and keep these fields in sync, update that method to call `_updateProcessMemoryFromResult(result)` instead of duplicating the parsing logic.
</issue_to_address>
### Comment 2
<location path="lib/widgets/voice_input.dart" line_range="101-115" />
<code_context>
+ }
+ }
+
+ Future<void> _finishListening() async {
+ if (_flushing) return;
+ _flushing = true;
+ try {
await _speech.stop();
if (mounted) setState(() => _isListening = false);
- if (_lastWords.isNotEmpty) {
- widget.onTranscription(_lastWords);
- _lastWords = '';
+ final transcript = _lastWords.trim();
+ _lastWords = '';
+ if (transcript.isNotEmpty) {
+ widget.onTranscription(transcript);
}
- } else {
- await _startListening();
+ } finally {
+ _flushing = false;
}
}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Guard `_finishListening` against errors from `_speech.stop()` to keep state consistent.
If `_speech.stop()` throws (e.g., from a plugin error), `_flushing` will reset but `_isListening` will stay true and `_lastWords` may be out of sync. Consider wrapping only `_speech.stop()` in a try/catch, logging the error, and explicitly forcing `_isListening = false` (and any other cleanup) in the catch block so the UI can recover from a failed stop.
```suggestion
Future<void> _finishListening() async {
if (_flushing) return;
_flushing = true;
try {
try {
await _speech.stop();
} catch (error, stackTrace) {
debugPrint('Error stopping speech recognition: $error');
debugPrint('$stackTrace');
if (mounted) {
setState(() {
_isListening = false;
});
} else {
_isListening = false;
}
_lastWords = '';
return;
}
if (mounted) setState(() => _isListening = false);
final transcript = _lastWords.trim();
_lastWords = '';
if (transcript.isNotEmpty) {
widget.onTranscription(transcript);
}
} finally {
_flushing = false;
}
}
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| Future<int?> readAvailableMemMb() async { | ||
| if (!kIsWeb && Platform.isAndroid) { | ||
| try { | ||
| final result = await _channel.invokeMethod<Map<Object?, Object?>>( | ||
| 'getProcessMemory', | ||
| ); | ||
| if (result != null) { | ||
| _lastPssKb = result['pssKb'] as int?; | ||
| _lastRssKb = result['rssKb'] as int?; | ||
| final avail = result['availMemMb']; |
There was a problem hiding this comment.
suggestion: Factor out shared parsing of availMemMb to reduce duplication and keep fields in sync.
Both methods currently parse availMemMb and update _lastAvailMemMb slightly differently. A small private helper that takes the result map and sets _lastPssKb, _lastRssKb, and _lastAvailMemMb in one place would remove duplication and guarantee these fields stay consistent across both code paths.
Suggested implementation:
int? _lastRssKb;
int? _lastAvailMemMb;
int? get lastPssMb =>
_lastPssKb != null ? (_lastPssKb! / 1024).round() : null;
int? get lastRssMb =>
_lastRssKb != null ? (_lastRssKb! / 1024).round() : null;
int? get lastAvailMemMb => _lastAvailMemMb;
/// Parses process memory info from a platform channel result map and updates
/// [_lastPssKb], [_lastRssKb] and [_lastAvailMemMb] consistently.
///
/// Returns the parsed available memory in MB, or null if it is not present.
int? _updateProcessMemoryFromResult(Map<Object?, Object?> result) {
_lastPssKb = result['pssKb'] as int?;
_lastRssKb = result['rssKb'] as int?;
final avail = result['availMemMb'];
if (avail is int) {
_lastAvailMemMb = avail;
} else if (avail is num) {
_lastAvailMemMb = avail.round();
} else {
_lastAvailMemMb = null;
}
return _lastAvailMemMb;
}
if (result != null) {
return _updateProcessMemoryFromResult(result);
}
There is at least one other method in this service that also parses pssKb, rssKb, and availMemMb from the platform channel result. To fully apply the refactor and keep these fields in sync, update that method to call _updateProcessMemoryFromResult(result) instead of duplicating the parsing logic.
| Future<void> _finishListening() async { | ||
| if (_flushing) return; | ||
| _flushing = true; | ||
| try { | ||
| await _speech.stop(); | ||
| if (mounted) setState(() => _isListening = false); | ||
| if (_lastWords.isNotEmpty) { | ||
| widget.onTranscription(_lastWords); | ||
| _lastWords = ''; | ||
| final transcript = _lastWords.trim(); | ||
| _lastWords = ''; | ||
| if (transcript.isNotEmpty) { | ||
| widget.onTranscription(transcript); | ||
| } | ||
| } else { | ||
| await _startListening(); | ||
| } finally { | ||
| _flushing = false; | ||
| } | ||
| } |
There was a problem hiding this comment.
suggestion (bug_risk): Guard _finishListening against errors from _speech.stop() to keep state consistent.
If _speech.stop() throws (e.g., from a plugin error), _flushing will reset but _isListening will stay true and _lastWords may be out of sync. Consider wrapping only _speech.stop() in a try/catch, logging the error, and explicitly forcing _isListening = false (and any other cleanup) in the catch block so the UI can recover from a failed stop.
| Future<void> _finishListening() async { | |
| if (_flushing) return; | |
| _flushing = true; | |
| try { | |
| await _speech.stop(); | |
| if (mounted) setState(() => _isListening = false); | |
| if (_lastWords.isNotEmpty) { | |
| widget.onTranscription(_lastWords); | |
| _lastWords = ''; | |
| final transcript = _lastWords.trim(); | |
| _lastWords = ''; | |
| if (transcript.isNotEmpty) { | |
| widget.onTranscription(transcript); | |
| } | |
| } else { | |
| await _startListening(); | |
| } finally { | |
| _flushing = false; | |
| } | |
| } | |
| Future<void> _finishListening() async { | |
| if (_flushing) return; | |
| _flushing = true; | |
| try { | |
| try { | |
| await _speech.stop(); | |
| } catch (error, stackTrace) { | |
| debugPrint('Error stopping speech recognition: $error'); | |
| debugPrint('$stackTrace'); | |
| if (mounted) { | |
| setState(() { | |
| _isListening = false; | |
| }); | |
| } else { | |
| _isListening = false; | |
| } | |
| _lastWords = ''; | |
| return; | |
| } | |
| if (mounted) setState(() => _isListening = false); | |
| final transcript = _lastWords.trim(); | |
| _lastWords = ''; | |
| if (transcript.isNotEmpty) { | |
| widget.onTranscription(transcript); | |
| } | |
| } finally { | |
| _flushing = false; | |
| } | |
| } |
| 'When the user asks to open an app, set/cancel an alarm, open settings, ' | ||
| 'or search the web, call the tool immediately — do not say you lack ' | ||
| 'device access. ' | ||
| 'open_app packages: com.google.android.youtube, com.android.settings, ' |
There was a problem hiding this comment.
WARNING: _deviceToolsCapabilitySuffix hardcodes only 3 packages while OpenAppIntentParser.knownApps supports 9 unique packages. When native function calling is unavailable and the model falls back to text-based JSON, the model cannot construct valid tool calls for packages not listed here (e.g., WhatsApp, Spotify, Maps).
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 1 Issue Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Files Reviewed (16 files)
Fix these issues in Kilo Cloud Reviewed by step-3.7-flash · Input: 141.7K · Output: 35.1K · Cached: 1.9M |
Summary
availMemMbfrom diagnostics channelTest plan
flutter test test/services/free_ram_gate_test.dartSummary by Sourcery
Add Android-specific RAM gating, app-opening, and sharing improvements to reduce crashes and improve device integration.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: