neoagent 3.1.1-beta.9 → 3.2.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.env.example +2 -2
  2. package/README.md +5 -1
  3. package/docs/architecture.md +1 -1
  4. package/docs/benchmarking.md +68 -87
  5. package/docs/capabilities.md +1 -1
  6. package/docs/clients-and-devices.md +3 -4
  7. package/docs/configuration.md +1 -1
  8. package/docs/devices.md +1 -2
  9. package/docs/health.md +24 -0
  10. package/docs/index.md +2 -2
  11. package/docs/integrations-architecture.md +6 -0
  12. package/docs/integrations.md +15 -1
  13. package/docs/models.md +1 -1
  14. package/docs/persistence.md +1 -1
  15. package/docs/why-neoagent.md +1 -1
  16. package/flutter_app/README.md +0 -5
  17. package/flutter_app/android/app/src/main/AndroidManifest.xml +0 -14
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +0 -98
  19. package/flutter_app/lib/main.dart +0 -6
  20. package/flutter_app/lib/main_app_shell.dart +0 -2
  21. package/flutter_app/lib/main_controller.dart +2 -867
  22. package/flutter_app/lib/main_devices.dart +0 -73
  23. package/flutter_app/lib/main_integrations.dart +339 -187
  24. package/flutter_app/lib/main_launcher.dart +2 -212
  25. package/flutter_app/lib/main_models.dart +0 -259
  26. package/flutter_app/lib/main_navigation.dart +2 -14
  27. package/flutter_app/lib/main_operations.dart +875 -383
  28. package/flutter_app/lib/main_runtime.dart +9 -208
  29. package/flutter_app/lib/main_settings.dart +17 -199
  30. package/flutter_app/lib/main_shared.dart +20 -591
  31. package/flutter_app/lib/main_timeline.dart +26 -122
  32. package/flutter_app/lib/main_voice_assistant.dart +0 -5
  33. package/flutter_app/lib/src/backend_client.dart +0 -102
  34. package/flutter_app/lib/src/desktop_companion_io.dart +1 -65
  35. package/flutter_app/lib/src/desktop_companion_stub.dart +0 -12
  36. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +0 -4
  37. package/flutter_app/linux/flutter/generated_plugins.cmake +0 -1
  38. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
  39. package/flutter_app/macos/Podfile.lock +56 -5
  40. package/flutter_app/pubspec.lock +0 -7
  41. package/flutter_app/pubspec.yaml +0 -2
  42. package/flutter_app/web/index.html +174 -0
  43. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +0 -3
  44. package/flutter_app/windows/flutter/generated_plugins.cmake +0 -1
  45. package/landing/index.html +0 -2
  46. package/lib/manager.js +1 -1
  47. package/lib/schema_migrations.js +75 -0
  48. package/package.json +1 -4
  49. package/server/db/database.js +1 -133
  50. package/server/http/middleware.js +2 -16
  51. package/server/http/routes.js +0 -2
  52. package/server/public/.last_build_id +1 -1
  53. package/server/public/assets/NOTICES +0 -25
  54. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  55. package/server/public/flutter_bootstrap.js +2 -2
  56. package/server/public/index.html +174 -0
  57. package/server/public/main.dart.js +92036 -91145
  58. package/server/routes/auth.js +11 -2
  59. package/server/routes/settings.js +0 -5
  60. package/server/routes/voice_assistant.js +1 -128
  61. package/server/services/account/erasure.js +0 -3
  62. package/server/services/ai/deliverables/artifact_helpers.js +1 -1
  63. package/server/services/ai/loop/conversation_loop.js +6 -2
  64. package/server/services/ai/loop/tool_dispatch.js +0 -3
  65. package/server/services/ai/messagingFallback.js +1 -1
  66. package/server/services/ai/settings.js +0 -6
  67. package/server/services/ai/toolEvidence.js +1 -4
  68. package/server/services/ai/tools.js +0 -234
  69. package/server/services/desktop/auth.js +0 -3
  70. package/server/services/desktop/registry.js +2 -50
  71. package/server/services/integrations/neoarchive/provider.js +616 -0
  72. package/server/services/integrations/neorecall/client.js +140 -0
  73. package/server/services/integrations/neorecall/constants.js +87 -0
  74. package/server/services/integrations/neorecall/provider.js +163 -0
  75. package/server/services/integrations/registry.js +4 -2
  76. package/server/services/manager.js +0 -22
  77. package/server/services/memory/manager.js +44 -19
  78. package/server/services/memory/retrieval_reasoning.js +36 -14
  79. package/server/services/messaging/formatting_guides.js +1 -0
  80. package/server/services/messaging/whatsapp.js +18 -0
  81. package/server/services/security/tool_categories.js +0 -5
  82. package/server/services/social_video/service.js +2 -20
  83. package/server/services/tasks/adapters/index.js +1 -1
  84. package/server/services/tasks/integration_runtime.js +3 -67
  85. package/server/services/tasks/runtime.js +1 -6
  86. package/server/services/tasks/task_repository.js +6 -13
  87. package/server/services/timeline/service.js +0 -224
  88. package/server/services/voice/message.js +0 -24
  89. package/server/services/voice/providers.js +42 -1
  90. package/server/services/voice/runtimeManager.js +9 -5
  91. package/server/services/voice/turnRunner.js +0 -22
  92. package/server/services/wearable/service.js +0 -5
  93. package/docs/recordings-and-health.md +0 -41
  94. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +0 -21
  95. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +0 -586
  96. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +0 -78
  97. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +0 -104
  98. package/flutter_app/lib/main_recordings.dart +0 -920
  99. package/flutter_app/lib/src/desktop_ocr_bridge.dart +0 -2
  100. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +0 -125
  101. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +0 -30
  102. package/flutter_app/lib/src/desktop_passive_history.dart +0 -332
  103. package/flutter_app/lib/src/recording_bridge.dart +0 -232
  104. package/flutter_app/lib/src/recording_bridge_io.dart +0 -1027
  105. package/flutter_app/lib/src/recording_bridge_stub.dart +0 -120
  106. package/flutter_app/lib/src/recording_bridge_web.dart +0 -702
  107. package/flutter_app/lib/src/recording_chunk_queue.dart +0 -149
  108. package/flutter_app/lib/src/recording_chunk_queue_io.dart +0 -182
  109. package/flutter_app/lib/src/recording_payloads.dart +0 -95
  110. package/flutter_app/third_party/desktop_audio_capture/LICENSE +0 -21
  111. package/flutter_app/third_party/desktop_audio_capture/README.md +0 -262
  112. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +0 -65
  113. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +0 -153
  114. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +0 -110
  115. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +0 -461
  116. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +0 -91
  117. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +0 -106
  118. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +0 -219
  119. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +0 -336
  120. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +0 -101
  121. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +0 -692
  122. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +0 -35
  123. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +0 -36
  124. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +0 -32
  125. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +0 -32
  126. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +0 -878
  127. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +0 -27
  128. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +0 -1172
  129. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +0 -655
  130. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +0 -12
  131. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +0 -30
  132. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +0 -87
  133. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +0 -105
  134. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +0 -80
  135. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +0 -31
  136. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +0 -12
  137. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +0 -23
  138. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +0 -25
  139. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +0 -1117
  140. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +0 -115
  141. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +0 -777
  142. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +0 -87
  143. package/server/routes/recordings.js +0 -335
  144. package/server/routes/screenHistory.js +0 -190
  145. package/server/services/ai/recordingInsights.js +0 -105
  146. package/server/services/integrations/neomail/provider.js +0 -993
  147. package/server/services/recordings/deepgram.js +0 -58
  148. package/server/services/recordings/manager.js +0 -1273
  149. package/server/services/tasks/adapters/neomail_email_received.js +0 -46
  150. package/server/services/voice/screenshotContext.js +0 -73
  151. package/server/services/voice/turnCoordinator.js +0 -51
@@ -505,10 +505,8 @@ class _SkillsPanelState extends State<SkillsPanel>
505
505
 
506
506
  // Installed tab search & filter state
507
507
  String _installedQuery = '';
508
- String _installedStatusFilter =
509
- 'all'; // 'all' | 'active' | 'draft' | 'disabled'
510
- String _installedSourceFilter =
511
- 'all'; // 'all' | 'built-in' | 'learned' | 'user' | 'store'
508
+ String _installedStatusFilter = 'all'; // 'all' | 'active' | 'draft' | 'disabled'
509
+ String _installedSourceFilter = 'all'; // 'all' | 'built-in' | 'learned' | 'user' | 'store'
512
510
  late final TextEditingController _installedSearchController;
513
511
 
514
512
  @override
@@ -647,36 +645,18 @@ class _SkillsPanelState extends State<SkillsPanel>
647
645
  final q = _installedQuery;
648
646
  if (q.isNotEmpty &&
649
647
  !skill.name.toLowerCase().contains(q) &&
650
- !skill.description.toLowerCase().contains(q)) {
651
- return false;
652
- }
648
+ !skill.description.toLowerCase().contains(q)) return false;
653
649
  if (_installedStatusFilter != 'all') {
654
- if (_installedStatusFilter == 'active' &&
655
- (!skill.enabled || skill.draft)) {
656
- return false;
657
- }
658
- if (_installedStatusFilter == 'draft' && !skill.draft) {
659
- return false;
660
- }
661
- if (_installedStatusFilter == 'disabled' && skill.enabled) {
662
- return false;
663
- }
664
- }
665
- if (_installedSourceFilter != 'all' &&
666
- skill.source != _installedSourceFilter) {
667
- return false;
650
+ if (_installedStatusFilter == 'active' && (!skill.enabled || skill.draft)) return false;
651
+ if (_installedStatusFilter == 'draft' && !skill.draft) return false;
652
+ if (_installedStatusFilter == 'disabled' && skill.enabled) return false;
668
653
  }
654
+ if (_installedSourceFilter != 'all' && skill.source != _installedSourceFilter) return false;
669
655
  return true;
670
656
  }).toList();
671
657
 
672
658
  final statusFilters = <String>['all', 'active', 'draft', 'disabled'];
673
- final sourceFilters = <String>[
674
- 'all',
675
- 'built-in',
676
- 'learned',
677
- 'user',
678
- 'store',
679
- ];
659
+ final sourceFilters = <String>['all', 'built-in', 'learned', 'user', 'store'];
680
660
 
681
661
  return Card(
682
662
  child: Column(
@@ -777,174 +757,157 @@ class _SkillsPanelState extends State<SkillsPanel>
777
757
  separatorBuilder: (_, __) => const SizedBox(height: 10),
778
758
  itemBuilder: (context, index) {
779
759
  final skill = filteredSkills[index];
780
- return LayoutBuilder(
781
- builder: (context, constraints) {
782
- final compact = constraints.maxWidth < 760;
783
- return Container(
784
- padding: const EdgeInsets.all(14),
785
- decoration: BoxDecoration(
786
- color: _bgSecondary,
787
- borderRadius: BorderRadius.circular(14),
788
- border: Border.all(color: _border),
789
- ),
790
- child: compact
791
- ? Column(
792
- crossAxisAlignment: CrossAxisAlignment.start,
793
- children: <Widget>[
794
- Row(
795
- children: <Widget>[
796
- Expanded(
797
- child: Text(
798
- skill.name,
799
- style: TextStyle(
800
- fontWeight: FontWeight.w700,
801
- ),
802
- ),
803
- ),
804
- Switch(
805
- value: skill.enabled,
806
- onChanged: (value) => controller
807
- .setSkillEnabled(skill.name, value),
808
- ),
809
- ],
810
- ),
811
- Text(
812
- skill.description.ifEmpty('No description'),
813
- style: TextStyle(color: _textSecondary),
814
- ),
815
- const SizedBox(height: 10),
816
- Wrap(
817
- spacing: 8,
818
- runSpacing: 8,
819
- children: <Widget>[
820
- _MetaPill(
821
- label: skill.category,
822
- icon: Icons.folder_outlined,
823
- ),
824
- _MetaPill(
825
- label: skill.source,
826
- icon: Icons.source_outlined,
827
- ),
828
- if (skill.draft)
829
- const _MetaPill(
830
- label: 'Draft',
831
- icon: Icons.edit_note_outlined,
832
- ),
833
- ],
834
- ),
835
- const SizedBox(height: 10),
836
- Row(
837
- children: <Widget>[
838
- const Spacer(),
839
- OutlinedButton(
840
- onPressed: () => _openSkillEditor(
841
- context,
842
- skill.name,
843
- ),
844
- child: Text('Open'),
845
- ),
846
- const SizedBox(width: 8),
847
- TextButton.icon(
848
- onPressed: () => _confirmDeleteSkill(
849
- context,
850
- skill.name,
851
- ),
852
- icon: Icon(Icons.delete_outline),
853
- style: TextButton.styleFrom(
854
- foregroundColor: _danger,
855
- ),
856
- label: Text('Delete'),
857
- ),
858
- ],
859
- ),
860
- ],
861
- )
862
- : Row(
863
- crossAxisAlignment: CrossAxisAlignment.start,
864
- children: <Widget>[
865
- Expanded(
866
- child: Column(
867
- crossAxisAlignment:
868
- CrossAxisAlignment.start,
869
- children: <Widget>[
870
- Text(
871
- skill.name,
872
- style: TextStyle(
873
- fontWeight: FontWeight.w700,
874
- ),
875
- ),
876
- const SizedBox(height: 6),
877
- Text(
878
- skill.description.ifEmpty(
879
- 'No description',
880
- ),
881
- style: TextStyle(
882
- color: _textSecondary,
883
- ),
884
- ),
885
- const SizedBox(height: 10),
886
- Wrap(
887
- spacing: 8,
888
- runSpacing: 8,
889
- children: <Widget>[
890
- _MetaPill(
891
- label: skill.category,
892
- icon: Icons.folder_outlined,
893
- ),
894
- _MetaPill(
895
- label: skill.source,
896
- icon: Icons.source_outlined,
897
- ),
898
- if (skill.draft)
899
- const _MetaPill(
900
- label: 'Draft',
901
- icon: Icons.edit_note_outlined,
902
- ),
903
- ],
904
- ),
905
- ],
760
+ return LayoutBuilder(
761
+ builder: (context, constraints) {
762
+ final compact = constraints.maxWidth < 760;
763
+ return Container(
764
+ padding: const EdgeInsets.all(14),
765
+ decoration: BoxDecoration(
766
+ color: _bgSecondary,
767
+ borderRadius: BorderRadius.circular(14),
768
+ border: Border.all(color: _border),
769
+ ),
770
+ child: compact
771
+ ? Column(
772
+ crossAxisAlignment: CrossAxisAlignment.start,
773
+ children: <Widget>[
774
+ Row(
775
+ children: <Widget>[
776
+ Expanded(
777
+ child: Text(
778
+ skill.name,
779
+ style: TextStyle(fontWeight: FontWeight.w700),
780
+ ),
781
+ ),
782
+ Switch(
783
+ value: skill.enabled,
784
+ onChanged: (value) => controller
785
+ .setSkillEnabled(skill.name, value),
786
+ ),
787
+ ],
788
+ ),
789
+ Text(
790
+ skill.description.ifEmpty('No description'),
791
+ style: TextStyle(color: _textSecondary),
792
+ ),
793
+ const SizedBox(height: 10),
794
+ Wrap(
795
+ spacing: 8,
796
+ runSpacing: 8,
797
+ children: <Widget>[
798
+ _MetaPill(
799
+ label: skill.category,
800
+ icon: Icons.folder_outlined,
801
+ ),
802
+ _MetaPill(
803
+ label: skill.source,
804
+ icon: Icons.source_outlined,
805
+ ),
806
+ if (skill.draft)
807
+ const _MetaPill(
808
+ label: 'Draft',
809
+ icon: Icons.edit_note_outlined,
810
+ ),
811
+ ],
812
+ ),
813
+ const SizedBox(height: 10),
814
+ Row(
815
+ children: <Widget>[
816
+ const Spacer(),
817
+ OutlinedButton(
818
+ onPressed: () =>
819
+ _openSkillEditor(context, skill.name),
820
+ child: Text('Open'),
821
+ ),
822
+ const SizedBox(width: 8),
823
+ TextButton.icon(
824
+ onPressed: () =>
825
+ _confirmDeleteSkill(context, skill.name),
826
+ icon: Icon(Icons.delete_outline),
827
+ style: TextButton.styleFrom(
828
+ foregroundColor: _danger,
829
+ ),
830
+ label: Text('Delete'),
831
+ ),
832
+ ],
833
+ ),
834
+ ],
835
+ )
836
+ : Row(
837
+ crossAxisAlignment: CrossAxisAlignment.start,
838
+ children: <Widget>[
839
+ Expanded(
840
+ child: Column(
841
+ crossAxisAlignment: CrossAxisAlignment.start,
842
+ children: <Widget>[
843
+ Text(
844
+ skill.name,
845
+ style: TextStyle(fontWeight: FontWeight.w700),
846
+ ),
847
+ const SizedBox(height: 6),
848
+ Text(
849
+ skill.description.ifEmpty('No description'),
850
+ style: TextStyle(color: _textSecondary),
851
+ ),
852
+ const SizedBox(height: 10),
853
+ Wrap(
854
+ spacing: 8,
855
+ runSpacing: 8,
856
+ children: <Widget>[
857
+ _MetaPill(
858
+ label: skill.category,
859
+ icon: Icons.folder_outlined,
906
860
  ),
907
- ),
908
- const SizedBox(width: 10),
909
- Column(
910
- children: <Widget>[
911
- Switch(
912
- value: skill.enabled,
913
- onChanged: (value) => controller
914
- .setSkillEnabled(skill.name, value),
915
- ),
916
- OutlinedButton(
917
- onPressed: () => _openSkillEditor(
918
- context,
919
- skill.name,
920
- ),
921
- child: Text('Open'),
922
- ),
923
- const SizedBox(height: 6),
924
- TextButton.icon(
925
- onPressed: () => _confirmDeleteSkill(
926
- context,
927
- skill.name,
928
- ),
929
- icon: Icon(Icons.delete_outline),
930
- style: TextButton.styleFrom(
931
- foregroundColor: _danger,
932
- ),
933
- label: Text('Delete'),
861
+ _MetaPill(
862
+ label: skill.source,
863
+ icon: Icons.source_outlined,
864
+ ),
865
+ if (skill.draft)
866
+ const _MetaPill(
867
+ label: 'Draft',
868
+ icon: Icons.edit_note_outlined,
934
869
  ),
935
- ],
936
- ),
937
- ],
870
+ ],
871
+ ),
872
+ ],
873
+ ),
874
+ ),
875
+ const SizedBox(width: 10),
876
+ Column(
877
+ children: <Widget>[
878
+ Switch(
879
+ value: skill.enabled,
880
+ onChanged: (value) => controller
881
+ .setSkillEnabled(skill.name, value),
938
882
  ),
939
- );
940
- },
941
- );
883
+ OutlinedButton(
884
+ onPressed: () =>
885
+ _openSkillEditor(context, skill.name),
886
+ child: Text('Open'),
887
+ ),
888
+ const SizedBox(height: 6),
889
+ TextButton.icon(
890
+ onPressed: () =>
891
+ _confirmDeleteSkill(context, skill.name),
892
+ icon: Icon(Icons.delete_outline),
893
+ style: TextButton.styleFrom(
894
+ foregroundColor: _danger,
895
+ ),
896
+ label: Text('Delete'),
897
+ ),
898
+ ],
899
+ ),
900
+ ],
901
+ ),
902
+ );
903
+ },
904
+ );
942
905
  },
943
906
  ),
944
907
  ),
945
- ],
946
- ),
947
- );
908
+ ],
909
+ ),
910
+ );
948
911
  }
949
912
 
950
913
  Widget _buildStoreTab(
@@ -1502,9 +1465,8 @@ class _MemoryPanelState extends State<MemoryPanel>
1502
1465
  );
1503
1466
  if (!mounted) return;
1504
1467
  _llmImportController.clear();
1505
- final warningText = result.warnings.isEmpty
1506
- ? ''
1507
- : ' ${result.warnings.join(' ')}';
1468
+ final warningText =
1469
+ result.warnings.isEmpty ? '' : ' ${result.warnings.join(' ')}';
1508
1470
  ScaffoldMessenger.of(context).showSnackBar(
1509
1471
  SnackBar(
1510
1472
  content: Text(
@@ -1532,7 +1494,9 @@ class _MemoryPanelState extends State<MemoryPanel>
1532
1494
  : controller.memories;
1533
1495
  if (_entityFilter == null) return base;
1534
1496
  return base
1535
- .where((m) => m.entities.any((e) => e.name == _entityFilter))
1497
+ .where(
1498
+ (m) => m.entities.any((e) => e.name == _entityFilter),
1499
+ )
1536
1500
  .toList();
1537
1501
  }
1538
1502
 
@@ -1624,10 +1588,7 @@ class _MemoryPanelState extends State<MemoryPanel>
1624
1588
  });
1625
1589
  }
1626
1590
 
1627
- void _openRetrievalInspector(
1628
- BuildContext context,
1629
- NeoAgentController controller,
1630
- ) {
1591
+ void _openRetrievalInspector(BuildContext context, NeoAgentController controller) {
1631
1592
  Navigator.of(context).push(
1632
1593
  MaterialPageRoute(
1633
1594
  builder: (context) => RetrievalInspectorView(controller: controller),
@@ -1642,8 +1603,7 @@ class _MemoryPanelState extends State<MemoryPanel>
1642
1603
  final memoriesToShow = _visibleMemories;
1643
1604
  final selectedIds = _selectedVisibleMemoryIds.toSet();
1644
1605
  final selectedCount = selectedIds.length;
1645
- final allVisibleSelected =
1646
- memoriesToShow.isNotEmpty &&
1606
+ final allVisibleSelected = memoriesToShow.isNotEmpty &&
1647
1607
  memoriesToShow.every((m) => selectedIds.contains(m.id));
1648
1608
  final showingSearchResults = controller.memoryRecallResults.isNotEmpty;
1649
1609
  final compact = MediaQuery.sizeOf(context).width < 760;
@@ -1684,7 +1644,9 @@ class _MemoryPanelState extends State<MemoryPanel>
1684
1644
  padding: const EdgeInsets.all(18),
1685
1645
  child: Row(
1686
1646
  children: <Widget>[
1687
- _MemoryConfidenceGauge(confidence: stats.averageConfidence),
1647
+ _MemoryConfidenceGauge(
1648
+ confidence: stats.averageConfidence,
1649
+ ),
1688
1650
  const SizedBox(width: 18),
1689
1651
  Expanded(
1690
1652
  child: Wrap(
@@ -1743,7 +1705,9 @@ class _MemoryPanelState extends State<MemoryPanel>
1743
1705
  children: <Widget>[
1744
1706
  Row(
1745
1707
  children: <Widget>[
1746
- Expanded(child: const _SectionTitle('Knowledge Graph')),
1708
+ Expanded(
1709
+ child: const _SectionTitle('Knowledge Graph'),
1710
+ ),
1747
1711
  if (_entityFilter != null)
1748
1712
  TextButton.icon(
1749
1713
  onPressed: () =>
@@ -1756,7 +1720,10 @@ class _MemoryPanelState extends State<MemoryPanel>
1756
1720
  const SizedBox(height: 4),
1757
1721
  Text(
1758
1722
  'Tap an entity to filter memories by it.',
1759
- style: TextStyle(color: _textSecondary, fontSize: 12),
1723
+ style: TextStyle(
1724
+ color: _textSecondary,
1725
+ fontSize: 12,
1726
+ ),
1760
1727
  ),
1761
1728
  const SizedBox(height: 14),
1762
1729
  SizedBox(
@@ -1890,16 +1857,15 @@ class _MemoryPanelState extends State<MemoryPanel>
1890
1857
  Wrap(
1891
1858
  spacing: 8,
1892
1859
  runSpacing: 8,
1893
- crossAxisAlignment: WrapCrossAlignment.center,
1860
+ crossAxisAlignment:
1861
+ WrapCrossAlignment.center,
1894
1862
  children: <Widget>[
1895
1863
  OutlinedButton.icon(
1896
- onPressed:
1897
- allVisibleSelected ||
1864
+ onPressed: allVisibleSelected ||
1898
1865
  _bulkActionInFlight
1899
1866
  ? null
1900
1867
  : () => _selectAllVisibleMemories(
1901
- memoriesToShow,
1902
- ),
1868
+ memoriesToShow),
1903
1869
  icon: Icon(
1904
1870
  Icons.done_all_outlined,
1905
1871
  size: 16,
@@ -1937,7 +1903,9 @@ class _MemoryPanelState extends State<MemoryPanel>
1937
1903
  Icons.archive_outlined,
1938
1904
  size: 16,
1939
1905
  ),
1940
- label: Text('Archive ($selectedCount)'),
1906
+ label: Text(
1907
+ 'Archive ($selectedCount)',
1908
+ ),
1941
1909
  ),
1942
1910
  OutlinedButton.icon(
1943
1911
  onPressed: _bulkActionInFlight
@@ -1955,7 +1923,9 @@ class _MemoryPanelState extends State<MemoryPanel>
1955
1923
  Icons.delete_sweep_outlined,
1956
1924
  size: 16,
1957
1925
  ),
1958
- label: Text('Delete ($selectedCount)'),
1926
+ label: Text(
1927
+ 'Delete ($selectedCount)',
1928
+ ),
1959
1929
  ),
1960
1930
  ],
1961
1931
  ],
@@ -1971,9 +1941,8 @@ class _MemoryPanelState extends State<MemoryPanel>
1971
1941
  )
1972
1942
  else
1973
1943
  ...memoriesToShow.map((memory) {
1974
- final isSelected = selectedIds.contains(
1975
- memory.id,
1976
- );
1944
+ final isSelected =
1945
+ selectedIds.contains(memory.id);
1977
1946
  return _MemoryRow(
1978
1947
  memory: memory,
1979
1948
  isSelected: isSelected,
@@ -1981,7 +1950,8 @@ class _MemoryPanelState extends State<MemoryPanel>
1981
1950
  memory.id,
1982
1951
  !isSelected,
1983
1952
  ),
1984
- onCheck: (value) => _toggleMemorySelection(
1953
+ onCheck: (value) =>
1954
+ _toggleMemorySelection(
1985
1955
  memory.id,
1986
1956
  value ?? false,
1987
1957
  ),
@@ -1994,9 +1964,9 @@ class _MemoryPanelState extends State<MemoryPanel>
1994
1964
  'This memory will be removed permanently.',
1995
1965
  onConfirm: () =>
1996
1966
  _deleteSingleMemory(
1997
- controller,
1998
- memory.id,
1999
- ),
1967
+ controller,
1968
+ memory.id,
1969
+ ),
2000
1970
  ),
2001
1971
  );
2002
1972
  }),
@@ -2015,7 +1985,9 @@ class _MemoryPanelState extends State<MemoryPanel>
2015
1985
  Expanded(
2016
1986
  child: Text(
2017
1987
  'Key-value pairs that persist across conversations.',
2018
- style: TextStyle(color: _textSecondary),
1988
+ style: TextStyle(
1989
+ color: _textSecondary,
1990
+ ),
2019
1991
  ),
2020
1992
  ),
2021
1993
  TextButton.icon(
@@ -2029,75 +2001,75 @@ class _MemoryPanelState extends State<MemoryPanel>
2029
2001
  ],
2030
2002
  ),
2031
2003
  const SizedBox(height: 10),
2032
- if (controller.memoryOverview.coreEntries.isEmpty)
2004
+ if (controller
2005
+ .memoryOverview.coreEntries.isEmpty)
2033
2006
  Text(
2034
2007
  'No core memory entries yet.',
2035
2008
  style: TextStyle(color: _textSecondary),
2036
2009
  )
2037
2010
  else
2038
- ...controller.memoryOverview.coreEntries.entries
2011
+ ...controller.memoryOverview.coreEntries
2012
+ .entries
2039
2013
  .map((entry) {
2040
- return Container(
2041
- width: double.infinity,
2042
- margin: const EdgeInsets.only(
2043
- bottom: 10,
2014
+ return Container(
2015
+ width: double.infinity,
2016
+ margin: const EdgeInsets.only(bottom: 10),
2017
+ padding: const EdgeInsets.all(12),
2018
+ decoration: BoxDecoration(
2019
+ color: _bgSecondary,
2020
+ borderRadius:
2021
+ BorderRadius.circular(12),
2022
+ border: Border.all(color: _border),
2023
+ ),
2024
+ child: Row(
2025
+ crossAxisAlignment:
2026
+ CrossAxisAlignment.start,
2027
+ children: <Widget>[
2028
+ Expanded(
2029
+ child: Column(
2030
+ crossAxisAlignment:
2031
+ CrossAxisAlignment.start,
2032
+ children: <Widget>[
2033
+ Text(
2034
+ entry.key,
2035
+ style: TextStyle(
2036
+ fontWeight: FontWeight.w700,
2037
+ ),
2038
+ ),
2039
+ const SizedBox(height: 6),
2040
+ Text(
2041
+ entry.value.toString(),
2042
+ ),
2043
+ ],
2044
+ ),
2044
2045
  ),
2045
- padding: const EdgeInsets.all(12),
2046
- decoration: BoxDecoration(
2047
- color: _bgSecondary,
2048
- borderRadius: BorderRadius.circular(
2049
- 12,
2046
+ IconButton(
2047
+ onPressed: () =>
2048
+ _openCoreMemoryEditor(
2049
+ context,
2050
+ controller,
2051
+ keyValue: entry,
2050
2052
  ),
2051
- border: Border.all(color: _border),
2053
+ icon: Icon(Icons.edit_outlined),
2052
2054
  ),
2053
- child: Row(
2054
- crossAxisAlignment:
2055
- CrossAxisAlignment.start,
2056
- children: <Widget>[
2057
- Expanded(
2058
- child: Column(
2059
- crossAxisAlignment:
2060
- CrossAxisAlignment.start,
2061
- children: <Widget>[
2062
- Text(
2063
- entry.key,
2064
- style: TextStyle(
2065
- fontWeight:
2066
- FontWeight.w700,
2067
- ),
2068
- ),
2069
- const SizedBox(height: 6),
2070
- Text(entry.value.toString()),
2071
- ],
2072
- ),
2073
- ),
2074
- IconButton(
2075
- onPressed: () =>
2076
- _openCoreMemoryEditor(
2077
- context,
2078
- controller,
2079
- keyValue: entry,
2080
- ),
2081
- icon: Icon(Icons.edit_outlined),
2082
- ),
2083
- IconButton(
2084
- onPressed: () => _confirmDelete(
2085
- context,
2086
- title:
2087
- 'Delete core memory entry?',
2088
- message:
2089
- 'Remove "${entry.key}" from core memory.',
2090
- onConfirm: () =>
2091
- controller.deleteCoreMemory(
2092
- entry.key,
2093
- ),
2094
- ),
2095
- icon: Icon(Icons.delete_outline),
2055
+ IconButton(
2056
+ onPressed: () => _confirmDelete(
2057
+ context,
2058
+ title:
2059
+ 'Delete core memory entry?',
2060
+ message:
2061
+ 'Remove "${entry.key}" from core memory.',
2062
+ onConfirm: () => controller
2063
+ .deleteCoreMemory(
2064
+ entry.key,
2096
2065
  ),
2097
- ],
2066
+ ),
2067
+ icon: Icon(Icons.delete_outline),
2098
2068
  ),
2099
- );
2100
- }),
2069
+ ],
2070
+ ),
2071
+ );
2072
+ }),
2101
2073
  ],
2102
2074
  ),
2103
2075
  ),
@@ -2120,8 +2092,11 @@ class _MemoryPanelState extends State<MemoryPanel>
2120
2092
  FilledButton.icon(
2121
2093
  onPressed: _llmPromptLoading
2122
2094
  ? null
2123
- : () => _loadLlmPrompt(controller),
2124
- icon: Icon(Icons.auto_awesome_outlined),
2095
+ : () =>
2096
+ _loadLlmPrompt(controller),
2097
+ icon: Icon(
2098
+ Icons.auto_awesome_outlined,
2099
+ ),
2125
2100
  label: Text(
2126
2101
  _llmPromptLoading
2127
2102
  ? 'Generating...'
@@ -2130,9 +2105,11 @@ class _MemoryPanelState extends State<MemoryPanel>
2130
2105
  ),
2131
2106
  OutlinedButton.icon(
2132
2107
  onPressed:
2133
- _llmPromptController.text.trim().isEmpty
2134
- ? null
2135
- : _copyLlmPrompt,
2108
+ _llmPromptController.text
2109
+ .trim()
2110
+ .isEmpty
2111
+ ? null
2112
+ : _copyLlmPrompt,
2136
2113
  icon: Icon(Icons.copy_all_outlined),
2137
2114
  label: Text('Copy Prompt'),
2138
2115
  ),
@@ -2145,7 +2122,8 @@ class _MemoryPanelState extends State<MemoryPanel>
2145
2122
  maxLines: 8,
2146
2123
  readOnly: true,
2147
2124
  decoration: const InputDecoration(
2148
- labelText: 'Prompt to paste into another AI',
2125
+ labelText:
2126
+ 'Prompt to paste into another AI',
2149
2127
  ),
2150
2128
  ),
2151
2129
  const SizedBox(height: 16),
@@ -2155,7 +2133,8 @@ class _MemoryPanelState extends State<MemoryPanel>
2155
2133
  onChanged: _llmImporting
2156
2134
  ? null
2157
2135
  : (value) => setState(
2158
- () => _llmApplyBehaviorNotes = value,
2136
+ () =>
2137
+ _llmApplyBehaviorNotes = value,
2159
2138
  ),
2160
2139
  title: Text('Apply behavior notes'),
2161
2140
  subtitle: Text(
@@ -2168,7 +2147,8 @@ class _MemoryPanelState extends State<MemoryPanel>
2168
2147
  onChanged: _llmImporting
2169
2148
  ? null
2170
2149
  : (value) => setState(
2171
- () => _llmApplyCoreMemory = value,
2150
+ () =>
2151
+ _llmApplyCoreMemory = value,
2172
2152
  ),
2173
2153
  title: Text('Apply core memory'),
2174
2154
  subtitle: Text(
@@ -2188,10 +2168,13 @@ class _MemoryPanelState extends State<MemoryPanel>
2188
2168
  FilledButton.icon(
2189
2169
  onPressed: _llmImporting
2190
2170
  ? null
2191
- : () => _importLlmMemories(controller),
2171
+ : () =>
2172
+ _importLlmMemories(controller),
2192
2173
  icon: Icon(Icons.file_download_outlined),
2193
2174
  label: Text(
2194
- _llmImporting ? 'Importing...' : 'Import',
2175
+ _llmImporting
2176
+ ? 'Importing...'
2177
+ : 'Import',
2195
2178
  ),
2196
2179
  ),
2197
2180
  ],
@@ -2470,10 +2453,9 @@ class _MemoryConfidenceGaugeState extends State<_MemoryConfidenceGauge>
2470
2453
  vsync: this,
2471
2454
  duration: const Duration(milliseconds: 1200),
2472
2455
  );
2473
- _progress = Tween<double>(
2474
- begin: 0,
2475
- end: widget.confidence,
2476
- ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
2456
+ _progress = Tween<double>(begin: 0, end: widget.confidence).animate(
2457
+ CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
2458
+ );
2477
2459
  _controller.forward();
2478
2460
  }
2479
2461
 
@@ -2481,10 +2463,12 @@ class _MemoryConfidenceGaugeState extends State<_MemoryConfidenceGauge>
2481
2463
  void didUpdateWidget(_MemoryConfidenceGauge oldWidget) {
2482
2464
  super.didUpdateWidget(oldWidget);
2483
2465
  if (oldWidget.confidence != widget.confidence) {
2484
- _progress = Tween<double>(begin: _progress.value, end: widget.confidence)
2485
- .animate(
2486
- CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
2487
- );
2466
+ _progress = Tween<double>(
2467
+ begin: _progress.value,
2468
+ end: widget.confidence,
2469
+ ).animate(
2470
+ CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
2471
+ );
2488
2472
  _controller
2489
2473
  ..reset()
2490
2474
  ..forward();
@@ -2674,7 +2658,10 @@ class _MemoryRow extends StatelessWidget {
2674
2658
  if (onDelete != null)
2675
2659
  IconButton(
2676
2660
  onPressed: onDelete,
2677
- icon: Icon(Icons.delete_outline, size: 18),
2661
+ icon: Icon(
2662
+ Icons.delete_outline,
2663
+ size: 18,
2664
+ ),
2678
2665
  ),
2679
2666
  ],
2680
2667
  ),
@@ -2699,7 +2686,10 @@ class _MemoryRow extends StatelessWidget {
2699
2686
  const SizedBox(height: 6),
2700
2687
  Text(
2701
2688
  memory.createdAtLabel,
2702
- style: TextStyle(fontSize: 11, color: _textMuted),
2689
+ style: TextStyle(
2690
+ fontSize: 11,
2691
+ color: _textMuted,
2692
+ ),
2703
2693
  ),
2704
2694
  ],
2705
2695
  ),
@@ -2788,31 +2778,27 @@ class _EntityGraphViewState extends State<_EntityGraphView>
2788
2778
  for (int i = 0; i < entities.length; i++) {
2789
2779
  final entity = entities[i];
2790
2780
  final sizeFactor = 0.4 + 0.6 * (entity.mentionCount / maxMention);
2791
- _nodes.add(
2792
- _GraphNode(
2793
- id: entity.name,
2794
- label: entity.name,
2795
- radius: 18 + 20 * sizeFactor,
2796
- color: _kindColors[entity.kind] ?? _kindColors['concept']!,
2797
- kind: entity.kind,
2798
- isReflection: false,
2799
- offsetPhase: i * 0.7,
2800
- ),
2801
- );
2781
+ _nodes.add(_GraphNode(
2782
+ id: entity.name,
2783
+ label: entity.name,
2784
+ radius: 18 + 20 * sizeFactor,
2785
+ color: _kindColors[entity.kind] ?? _kindColors['concept']!,
2786
+ kind: entity.kind,
2787
+ isReflection: false,
2788
+ offsetPhase: i * 0.7,
2789
+ ));
2802
2790
  }
2803
2791
 
2804
2792
  for (int i = 0; i < views.length && i < 6; i++) {
2805
- _nodes.add(
2806
- _GraphNode(
2807
- id: 'kv_${views[i].title}',
2808
- label: views[i].title,
2809
- radius: 14,
2810
- color: const Color(0xFF8B7EC8),
2811
- kind: views[i].viewType,
2812
- isReflection: true,
2813
- offsetPhase: (entities.length + i) * 0.9,
2814
- ),
2815
- );
2793
+ _nodes.add(_GraphNode(
2794
+ id: 'kv_${views[i].title}',
2795
+ label: views[i].title,
2796
+ radius: 14,
2797
+ color: const Color(0xFF8B7EC8),
2798
+ kind: views[i].viewType,
2799
+ isReflection: true,
2800
+ offsetPhase: (entities.length + i) * 0.9,
2801
+ ));
2816
2802
  }
2817
2803
 
2818
2804
  _layoutDone = false;
@@ -2953,12 +2939,10 @@ class _EntityGraphPainter extends CustomPainter {
2953
2939
  void paint(Canvas canvas, Size size) {
2954
2940
  if (nodes.isEmpty) return;
2955
2941
 
2956
- final entityNodes = nodes
2957
- .where((n) => !n.isReflection)
2958
- .toList(growable: false);
2959
- final reflectionNodes = nodes
2960
- .where((n) => n.isReflection)
2961
- .toList(growable: false);
2942
+ final entityNodes =
2943
+ nodes.where((n) => !n.isReflection).toList(growable: false);
2944
+ final reflectionNodes =
2945
+ nodes.where((n) => n.isReflection).toList(growable: false);
2962
2946
 
2963
2947
  // Draw connections between entity nodes (subtle web)
2964
2948
  final linePaint = Paint()
@@ -2990,8 +2974,8 @@ class _EntityGraphPainter extends CustomPainter {
2990
2974
  var closest = entityNodes.first;
2991
2975
  var minDist = double.infinity;
2992
2976
  for (final en in entityNodes) {
2993
- final d =
2994
- (en.x - rn.x) * (en.x - rn.x) + (en.y - rn.y) * (en.y - rn.y);
2977
+ final d = (en.x - rn.x) * (en.x - rn.x) +
2978
+ (en.y - rn.y) * (en.y - rn.y);
2995
2979
  if (d < minDist) {
2996
2980
  minDist = d;
2997
2981
  closest = en;
@@ -3018,9 +3002,8 @@ class _EntityGraphPainter extends CustomPainter {
3018
3002
  // Glow
3019
3003
  if (isSelected || isHovered) {
3020
3004
  final glowPaint = Paint()
3021
- ..color = (isSelected ? accentColor : node.color).withValues(
3022
- alpha: 0.22,
3023
- )
3005
+ ..color = (isSelected ? accentColor : node.color)
3006
+ .withValues(alpha: 0.22)
3024
3007
  ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
3025
3008
  canvas.drawCircle(Offset(cx, cy), r + 6, glowPaint);
3026
3009
  }
@@ -3040,8 +3023,8 @@ class _EntityGraphPainter extends CustomPainter {
3040
3023
  ..color = isSelected
3041
3024
  ? accentColor
3042
3025
  : (isHovered
3043
- ? node.color.withValues(alpha: 0.8)
3044
- : node.color.withValues(alpha: 0.35))
3026
+ ? node.color.withValues(alpha: 0.8)
3027
+ : node.color.withValues(alpha: 0.35))
3045
3028
  ..style = PaintingStyle.stroke
3046
3029
  ..strokeWidth = isSelected ? 2.5 : 1.5;
3047
3030
  canvas.drawCircle(Offset(cx, cy), r, borderPaint);
@@ -3058,7 +3041,10 @@ class _EntityGraphPainter extends CustomPainter {
3058
3041
  maxLines: 1,
3059
3042
  ellipsis: '…',
3060
3043
  )..layout(maxWidth: r * 3);
3061
- tp.paint(canvas, Offset(cx - tp.width / 2, cy + r + 5));
3044
+ tp.paint(
3045
+ canvas,
3046
+ Offset(cx - tp.width / 2, cy + r + 5),
3047
+ );
3062
3048
  }
3063
3049
  }
3064
3050
 
@@ -5039,15 +5025,6 @@ const List<_TaskTriggerOption> _taskTriggerOptions = <_TaskTriggerOption>[
5039
5025
  providerKey: 'google_workspace',
5040
5026
  appKey: 'gmail',
5041
5027
  ),
5042
- _TaskTriggerOption(
5043
- type: 'neomail_email_received',
5044
- section: 'Email',
5045
- label: 'NeoMail Email Received',
5046
- description: 'Run when a matching message is synced into NeoMail.',
5047
- icon: Icons.mark_email_unread_rounded,
5048
- providerKey: 'neomail',
5049
- appKey: 'mailbox',
5050
- ),
5051
5028
  _TaskTriggerOption(
5052
5029
  type: 'outlook_email_received',
5053
5030
  section: 'Email',
@@ -5110,6 +5087,284 @@ _TaskTriggerOption _taskTriggerOptionForType(String type) {
5110
5087
  );
5111
5088
  }
5112
5089
 
5090
+ class _TaskSchedulePreset {
5091
+ const _TaskSchedulePreset({
5092
+ required this.id,
5093
+ required this.label,
5094
+ required this.description,
5095
+ required this.icon,
5096
+ });
5097
+
5098
+ final String id;
5099
+ final String label;
5100
+ final String description;
5101
+ final IconData icon;
5102
+ }
5103
+
5104
+ const List<_TaskSchedulePreset> _taskSchedulePresets = <_TaskSchedulePreset>[
5105
+ _TaskSchedulePreset(
5106
+ id: 'every_15_minutes',
5107
+ label: 'Every 15 minutes',
5108
+ description: 'Runs four times per hour.',
5109
+ icon: Icons.timer_outlined,
5110
+ ),
5111
+ _TaskSchedulePreset(
5112
+ id: 'every_30_minutes',
5113
+ label: 'Every 30 minutes',
5114
+ description: 'Runs twice per hour.',
5115
+ icon: Icons.timelapse_rounded,
5116
+ ),
5117
+ _TaskSchedulePreset(
5118
+ id: 'hourly',
5119
+ label: 'Hourly',
5120
+ description: 'Runs once per hour.',
5121
+ icon: Icons.schedule_rounded,
5122
+ ),
5123
+ _TaskSchedulePreset(
5124
+ id: 'daily',
5125
+ label: 'Daily',
5126
+ description: 'Runs every day at the selected time.',
5127
+ icon: Icons.today_rounded,
5128
+ ),
5129
+ _TaskSchedulePreset(
5130
+ id: 'weekdays',
5131
+ label: 'Weekdays',
5132
+ description: 'Runs Monday through Friday.',
5133
+ icon: Icons.work_outline_rounded,
5134
+ ),
5135
+ _TaskSchedulePreset(
5136
+ id: 'weekly',
5137
+ label: 'Weekly',
5138
+ description: 'Runs on selected weekdays.',
5139
+ icon: Icons.view_week_rounded,
5140
+ ),
5141
+ _TaskSchedulePreset(
5142
+ id: 'monthly',
5143
+ label: 'Monthly',
5144
+ description: 'Runs once per month on the selected day.',
5145
+ icon: Icons.calendar_month_rounded,
5146
+ ),
5147
+ _TaskSchedulePreset(
5148
+ id: 'custom',
5149
+ label: 'Custom Cron',
5150
+ description: 'Advanced manual schedule for special cases.',
5151
+ icon: Icons.tune_rounded,
5152
+ ),
5153
+ ];
5154
+
5155
+ const List<String> _taskWeekdayLabels = <String>[
5156
+ 'Mon',
5157
+ 'Tue',
5158
+ 'Wed',
5159
+ 'Thu',
5160
+ 'Fri',
5161
+ 'Sat',
5162
+ 'Sun',
5163
+ ];
5164
+
5165
+ _TaskSchedulePreset _taskSchedulePresetForId(String id) {
5166
+ return _taskSchedulePresets.firstWhere(
5167
+ (entry) => entry.id == id,
5168
+ orElse: () => _taskSchedulePresets[1],
5169
+ );
5170
+ }
5171
+
5172
+ class _TaskScheduleDraft {
5173
+ _TaskScheduleDraft({
5174
+ required this.mode,
5175
+ required this.presetId,
5176
+ required this.time,
5177
+ required this.weekdays,
5178
+ required this.monthDay,
5179
+ required this.customCronExpression,
5180
+ });
5181
+
5182
+ factory _TaskScheduleDraft.fromTask(TaskItem? task) {
5183
+ final runAt = task?.triggerConfig['runAt']?.toString().trim() ?? '';
5184
+ if (runAt.isNotEmpty) {
5185
+ return _TaskScheduleDraft(
5186
+ mode: 'one_time',
5187
+ presetId: 'daily',
5188
+ time: const TimeOfDay(hour: 9, minute: 0),
5189
+ weekdays: <int>{1},
5190
+ monthDay: 1,
5191
+ customCronExpression: '',
5192
+ );
5193
+ }
5194
+ final cron =
5195
+ task?.triggerConfig['cronExpression']?.toString().trim() ??
5196
+ '*/30 * * * *';
5197
+ final parsed = _parseCronExpression(cron);
5198
+ if (parsed != null) return parsed;
5199
+ return _TaskScheduleDraft(
5200
+ mode: 'recurring',
5201
+ presetId: 'custom',
5202
+ time: const TimeOfDay(hour: 9, minute: 0),
5203
+ weekdays: <int>{1},
5204
+ monthDay: 1,
5205
+ customCronExpression: cron,
5206
+ );
5207
+ }
5208
+
5209
+ String mode;
5210
+ String presetId;
5211
+ TimeOfDay time;
5212
+ Set<int> weekdays;
5213
+ int monthDay;
5214
+ String customCronExpression;
5215
+
5216
+ bool get usesTime =>
5217
+ presetId == 'daily' ||
5218
+ presetId == 'weekdays' ||
5219
+ presetId == 'weekly' ||
5220
+ presetId == 'monthly';
5221
+
5222
+ String get cronExpression {
5223
+ if (presetId == 'custom') return customCronExpression;
5224
+ final minute = time.minute.toString();
5225
+ final hour = time.hour.toString();
5226
+ return switch (presetId) {
5227
+ 'every_15_minutes' => '*/15 * * * *',
5228
+ 'every_30_minutes' => '*/30 * * * *',
5229
+ 'hourly' => '0 * * * *',
5230
+ 'daily' => '$minute $hour * * *',
5231
+ 'weekdays' => '$minute $hour * * 1-5',
5232
+ 'weekly' => '$minute $hour * * ${_cronWeekdays(weekdays)}',
5233
+ 'monthly' => '$minute $hour $monthDay * *',
5234
+ 'custom' => customCronExpression,
5235
+ _ => '*/30 * * * *',
5236
+ };
5237
+ }
5238
+
5239
+ String get summary {
5240
+ if (mode == 'one_time') return 'One-time run';
5241
+ if (presetId == 'custom') return 'Custom Cron';
5242
+ final preset = _taskSchedulePresetForId(presetId);
5243
+ if (!usesTime) return preset.label;
5244
+ final timeLabel = _formatTaskScheduleTime(time);
5245
+ if (presetId == 'weekly') {
5246
+ return '${preset.label} ${_formatTaskWeekdays(weekdays)} at $timeLabel';
5247
+ }
5248
+ if (presetId == 'monthly') {
5249
+ return '${preset.label} on day $monthDay at $timeLabel';
5250
+ }
5251
+ return '${preset.label} at $timeLabel';
5252
+ }
5253
+ }
5254
+
5255
+ _TaskScheduleDraft? _parseCronExpression(String cron) {
5256
+ final fields = cron.trim().split(RegExp(r'\s+'));
5257
+ if (fields.length != 5) return null;
5258
+ final minute = fields[0];
5259
+ final hour = fields[1];
5260
+ final dayOfMonth = fields[2];
5261
+ final month = fields[3];
5262
+ final dayOfWeek = fields[4];
5263
+ if (cron == '*/15 * * * *') {
5264
+ return _recurringScheduleDraft('every_15_minutes');
5265
+ }
5266
+ if (cron == '*/30 * * * *') {
5267
+ return _recurringScheduleDraft('every_30_minutes');
5268
+ }
5269
+ if (cron == '0 * * * *') return _recurringScheduleDraft('hourly');
5270
+ final parsedMinute = int.tryParse(minute);
5271
+ final parsedHour = int.tryParse(hour);
5272
+ if (parsedMinute == null ||
5273
+ parsedMinute < 0 ||
5274
+ parsedMinute > 59 ||
5275
+ parsedHour == null ||
5276
+ parsedHour < 0 ||
5277
+ parsedHour > 23 ||
5278
+ month != '*') {
5279
+ return null;
5280
+ }
5281
+ final time = TimeOfDay(hour: parsedHour, minute: parsedMinute);
5282
+ if (dayOfMonth == '*' && dayOfWeek == '*') {
5283
+ return _recurringScheduleDraft('daily', time: time);
5284
+ }
5285
+ if (dayOfMonth == '*' && dayOfWeek == '1-5') {
5286
+ return _recurringScheduleDraft('weekdays', time: time);
5287
+ }
5288
+ if (dayOfMonth == '*') {
5289
+ final weekdays = _parseCronWeekdays(dayOfWeek);
5290
+ if (weekdays == null || weekdays.isEmpty) return null;
5291
+ return _recurringScheduleDraft('weekly', time: time, weekdays: weekdays);
5292
+ }
5293
+ if (dayOfWeek == '*') {
5294
+ final parsedDay = int.tryParse(dayOfMonth);
5295
+ if (parsedDay == null || parsedDay < 1 || parsedDay > 31) return null;
5296
+ return _recurringScheduleDraft('monthly', time: time, monthDay: parsedDay);
5297
+ }
5298
+ return null;
5299
+ }
5300
+
5301
+ bool _looksLikeCronExpression(String cron) {
5302
+ return cron.trim().split(RegExp(r'\s+')).length == 5;
5303
+ }
5304
+
5305
+ _TaskScheduleDraft _recurringScheduleDraft(
5306
+ String presetId, {
5307
+ TimeOfDay time = const TimeOfDay(hour: 9, minute: 0),
5308
+ Set<int> weekdays = const <int>{1},
5309
+ int monthDay = 1,
5310
+ }) {
5311
+ return _TaskScheduleDraft(
5312
+ mode: 'recurring',
5313
+ presetId: presetId,
5314
+ time: time,
5315
+ weekdays: Set<int>.from(weekdays),
5316
+ monthDay: monthDay,
5317
+ customCronExpression: '',
5318
+ );
5319
+ }
5320
+
5321
+ Set<int>? _parseCronWeekdays(String value) {
5322
+ final result = <int>{};
5323
+ for (final part in value.split(',')) {
5324
+ final trimmed = part.trim();
5325
+ if (trimmed.isEmpty) return null;
5326
+ final rangeParts = trimmed.split('-');
5327
+ if (rangeParts.length == 2) {
5328
+ final start = _parseCronWeekday(rangeParts[0]);
5329
+ final end = _parseCronWeekday(rangeParts[1]);
5330
+ if (start == null || end == null || start > end) return null;
5331
+ for (var day = start; day <= end; day += 1) {
5332
+ result.add(day);
5333
+ }
5334
+ } else if (rangeParts.length == 1) {
5335
+ final day = _parseCronWeekday(trimmed);
5336
+ if (day == null) return null;
5337
+ result.add(day);
5338
+ } else {
5339
+ return null;
5340
+ }
5341
+ }
5342
+ return result;
5343
+ }
5344
+
5345
+ int? _parseCronWeekday(String value) {
5346
+ final parsed = int.tryParse(value.trim());
5347
+ if (parsed == null || parsed < 0 || parsed > 7) return null;
5348
+ return parsed == 0 ? 7 : parsed;
5349
+ }
5350
+
5351
+ String _cronWeekdays(Set<int> weekdays) {
5352
+ final sorted = weekdays.where((day) => day >= 1 && day <= 7).toList()..sort();
5353
+ if (sorted.isEmpty) return '1';
5354
+ return sorted.join(',');
5355
+ }
5356
+
5357
+ String _formatTaskScheduleTime(TimeOfDay time) {
5358
+ final hour = time.hour.toString().padLeft(2, '0');
5359
+ final minute = time.minute.toString().padLeft(2, '0');
5360
+ return '$hour:$minute';
5361
+ }
5362
+
5363
+ String _formatTaskWeekdays(Set<int> weekdays) {
5364
+ final sorted = weekdays.where((day) => day >= 1 && day <= 7).toList()..sort();
5365
+ if (sorted.isEmpty) return 'Monday';
5366
+ return sorted.map((day) => _taskWeekdayLabels[day - 1]).join(', ');
5367
+ }
5113
5368
  Future<String?> _pickTaskTriggerType(
5114
5369
  BuildContext context,
5115
5370
  String selectedType,
@@ -6277,8 +6532,9 @@ class _TasksPanelState extends State<TasksPanel> {
6277
6532
  }) async {
6278
6533
  final nameController = TextEditingController(text: task?.name ?? '');
6279
6534
  final triggerType = ValueNotifier<String>(task?.triggerType ?? 'schedule');
6280
- final cronController = TextEditingController(
6281
- text: task?.triggerConfig['cronExpression']?.toString() ?? '*/30 * * * *',
6535
+ final scheduleDraft = _TaskScheduleDraft.fromTask(task);
6536
+ final customCronController = TextEditingController(
6537
+ text: scheduleDraft.customCronExpression,
6282
6538
  );
6283
6539
  final runAtController = TextEditingController(
6284
6540
  text: task?.triggerConfig['runAt']?.toString() ?? '',
@@ -6289,7 +6545,9 @@ class _TasksPanelState extends State<TasksPanel> {
6289
6545
  final selectedConnectionId = ValueNotifier<int?>(
6290
6546
  task?.triggerConfig['connectionId'] is int
6291
6547
  ? task!.triggerConfig['connectionId'] as int
6292
- : int.tryParse(task?.triggerConfig['connectionId']?.toString() ?? ''),
6548
+ : int.tryParse(
6549
+ task?.triggerConfig['connectionId']?.toString() ?? '',
6550
+ ),
6293
6551
  );
6294
6552
  final selectedDeliveryTarget = ValueNotifier<TaskDeliveryTarget?>(
6295
6553
  _taskDeliveryTargetFromTask(task),
@@ -6312,8 +6570,6 @@ class _TasksPanelState extends State<TasksPanel> {
6312
6570
  );
6313
6571
  final channelController = TextEditingController(
6314
6572
  text:
6315
- task?.triggerConfig['folder']?.toString() ??
6316
- task?.triggerConfig['folderId']?.toString() ??
6317
6573
  task?.triggerConfig['channel']?.toString() ??
6318
6574
  task?.triggerConfig['chatId']?.toString() ??
6319
6575
  '',
@@ -6485,21 +6741,246 @@ class _TasksPanelState extends State<TasksPanel> {
6485
6741
  if (selectedTriggerType == 'schedule') {
6486
6742
  return Column(
6487
6743
  children: <Widget>[
6488
- TextField(
6489
- controller: cronController,
6490
- decoration: const InputDecoration(
6491
- labelText: 'Cron Expression',
6492
- helperText:
6493
- 'Use cron for recurring tasks. Leave Run At empty for recurring schedules.',
6494
- ),
6744
+ SegmentedButton<String>(
6745
+ segments: const <ButtonSegment<String>>[
6746
+ ButtonSegment<String>(
6747
+ value: 'recurring',
6748
+ icon: Icon(Icons.repeat_rounded),
6749
+ label: Text('Recurring'),
6750
+ ),
6751
+ ButtonSegment<String>(
6752
+ value: 'one_time',
6753
+ icon: Icon(Icons.event_rounded),
6754
+ label: Text('Once'),
6755
+ ),
6756
+ ],
6757
+ selected: <String>{scheduleDraft.mode},
6758
+ onSelectionChanged: (selection) {
6759
+ setLocalState(() {
6760
+ scheduleDraft.mode = selection.first;
6761
+ if (scheduleDraft.mode == 'recurring') {
6762
+ runAtController.clear();
6763
+ }
6764
+ });
6765
+ },
6495
6766
  ),
6496
6767
  const SizedBox(height: 12),
6497
- TextField(
6498
- controller: runAtController,
6499
- decoration: const InputDecoration(
6500
- labelText: 'Run At (optional ISO datetime)',
6768
+ if (scheduleDraft.mode == 'one_time')
6769
+ TextField(
6770
+ controller: runAtController,
6771
+ decoration: const InputDecoration(
6772
+ labelText: 'Run At',
6773
+ helperText:
6774
+ 'Use a date and time, for example 2026-07-03T09:00:00.',
6775
+ ),
6776
+ )
6777
+ else ...<Widget>[
6778
+ DropdownButtonFormField<String>(
6779
+ initialValue: scheduleDraft.presetId,
6780
+ isExpanded: true,
6781
+ decoration: const InputDecoration(
6782
+ labelText: 'Repeat',
6783
+ ),
6784
+ items: <DropdownMenuItem<String>>[
6785
+ ..._taskSchedulePresets.map(
6786
+ (preset) => DropdownMenuItem<String>(
6787
+ value: preset.id,
6788
+ child: Text(preset.label),
6789
+ ),
6790
+ ),
6791
+ ],
6792
+ onChanged: (value) {
6793
+ if (value == null) return;
6794
+ setLocalState(() {
6795
+ final currentCron =
6796
+ scheduleDraft.cronExpression;
6797
+ scheduleDraft.presetId = value;
6798
+ if (value == 'custom' &&
6799
+ customCronController.text
6800
+ .trim()
6801
+ .isEmpty) {
6802
+ customCronController.text =
6803
+ currentCron;
6804
+ scheduleDraft.customCronExpression =
6805
+ currentCron;
6806
+ }
6807
+ });
6808
+ },
6501
6809
  ),
6502
- ),
6810
+ const SizedBox(height: 12),
6811
+ if (scheduleDraft.presetId ==
6812
+ 'custom') ...<Widget>[
6813
+ TextField(
6814
+ controller: customCronController,
6815
+ onChanged: (value) =>
6816
+ scheduleDraft.customCronExpression =
6817
+ value,
6818
+ decoration: const InputDecoration(
6819
+ labelText: 'Cron expression',
6820
+ helperText:
6821
+ 'Advanced: minute hour day month weekday.',
6822
+ ),
6823
+ ),
6824
+ const SizedBox(height: 12),
6825
+ ],
6826
+ if (scheduleDraft.usesTime) ...<Widget>[
6827
+ InkWell(
6828
+ borderRadius: BorderRadius.circular(14),
6829
+ onTap: () async {
6830
+ final picked = await showTimePicker(
6831
+ context: context,
6832
+ initialTime: scheduleDraft.time,
6833
+ builder: (context, child) {
6834
+ return MediaQuery(
6835
+ data: MediaQuery.of(context)
6836
+ .copyWith(
6837
+ alwaysUse24HourFormat: true,
6838
+ ),
6839
+ child: child!,
6840
+ );
6841
+ },
6842
+ );
6843
+ if (picked == null) return;
6844
+ setLocalState(() {
6845
+ scheduleDraft.time = picked;
6846
+ });
6847
+ },
6848
+ child: InputDecorator(
6849
+ decoration: const InputDecoration(
6850
+ labelText: 'Time',
6851
+ ),
6852
+ child: Row(
6853
+ children: <Widget>[
6854
+ const Icon(
6855
+ Icons.access_time_rounded,
6856
+ ),
6857
+ const SizedBox(width: 12),
6858
+ Expanded(
6859
+ child: Text(
6860
+ _formatTaskScheduleTime(
6861
+ scheduleDraft.time,
6862
+ ),
6863
+ style: const TextStyle(
6864
+ fontWeight: FontWeight.w700,
6865
+ ),
6866
+ ),
6867
+ ),
6868
+ Icon(
6869
+ Icons.unfold_more_rounded,
6870
+ color: _textSecondary,
6871
+ ),
6872
+ ],
6873
+ ),
6874
+ ),
6875
+ ),
6876
+ const SizedBox(height: 12),
6877
+ ],
6878
+ if (scheduleDraft.presetId ==
6879
+ 'weekly') ...<Widget>[
6880
+ Align(
6881
+ alignment: Alignment.centerLeft,
6882
+ child: Wrap(
6883
+ spacing: 8,
6884
+ runSpacing: 8,
6885
+ children: List<Widget>.generate(7, (
6886
+ index,
6887
+ ) {
6888
+ final day = index + 1;
6889
+ return ChoiceChip(
6890
+ label: Text(
6891
+ _taskWeekdayLabels[index],
6892
+ ),
6893
+ selected: scheduleDraft.weekdays
6894
+ .contains(day),
6895
+ onSelected: (selected) {
6896
+ setLocalState(() {
6897
+ if (selected) {
6898
+ scheduleDraft.weekdays.add(
6899
+ day,
6900
+ );
6901
+ } else if (scheduleDraft
6902
+ .weekdays
6903
+ .length >
6904
+ 1) {
6905
+ scheduleDraft.weekdays.remove(
6906
+ day,
6907
+ );
6908
+ }
6909
+ });
6910
+ },
6911
+ );
6912
+ }),
6913
+ ),
6914
+ ),
6915
+ const SizedBox(height: 12),
6916
+ ],
6917
+ if (scheduleDraft.presetId ==
6918
+ 'monthly') ...<Widget>[
6919
+ DropdownButtonFormField<int>(
6920
+ initialValue: scheduleDraft.monthDay,
6921
+ decoration: const InputDecoration(
6922
+ labelText: 'Day of month',
6923
+ ),
6924
+ items:
6925
+ List<DropdownMenuItem<int>>.generate(
6926
+ 31,
6927
+ (index) => DropdownMenuItem<int>(
6928
+ value: index + 1,
6929
+ child: Text('Day ${index + 1}'),
6930
+ ),
6931
+ ),
6932
+ onChanged: (value) {
6933
+ if (value == null) return;
6934
+ setLocalState(() {
6935
+ scheduleDraft.monthDay = value;
6936
+ });
6937
+ },
6938
+ ),
6939
+ const SizedBox(height: 12),
6940
+ ],
6941
+ InputDecorator(
6942
+ decoration: const InputDecoration(
6943
+ labelText: 'Schedule',
6944
+ ),
6945
+ child: Builder(
6946
+ builder: (context) {
6947
+ final preset = _taskSchedulePresetForId(
6948
+ scheduleDraft.presetId,
6949
+ );
6950
+ return Row(
6951
+ children: <Widget>[
6952
+ Icon(preset.icon, color: _accent),
6953
+ const SizedBox(width: 12),
6954
+ Expanded(
6955
+ child: Column(
6956
+ crossAxisAlignment:
6957
+ CrossAxisAlignment.start,
6958
+ mainAxisSize: MainAxisSize.min,
6959
+ children: <Widget>[
6960
+ Text(
6961
+ scheduleDraft.summary,
6962
+ style: const TextStyle(
6963
+ fontWeight:
6964
+ FontWeight.w700,
6965
+ ),
6966
+ ),
6967
+ const SizedBox(height: 3),
6968
+ Text(
6969
+ preset.description,
6970
+ style: TextStyle(
6971
+ color: _textSecondary,
6972
+ fontSize: 12,
6973
+ ),
6974
+ ),
6975
+ ],
6976
+ ),
6977
+ ),
6978
+ ],
6979
+ );
6980
+ },
6981
+ ),
6982
+ ),
6983
+ ],
6503
6984
  ],
6504
6985
  );
6505
6986
  }
@@ -6534,8 +7015,6 @@ class _TasksPanelState extends State<TasksPanel> {
6534
7015
  ],
6535
7016
  if (selectedTriggerType ==
6536
7017
  'gmail_message_received' ||
6537
- selectedTriggerType ==
6538
- 'neomail_email_received' ||
6539
7018
  selectedTriggerType ==
6540
7019
  'outlook_email_received') ...<Widget>[
6541
7020
  TextField(
@@ -6553,18 +7032,6 @@ class _TasksPanelState extends State<TasksPanel> {
6553
7032
  setLocalState(() => unreadOnly = value),
6554
7033
  ),
6555
7034
  ],
6556
- if (selectedTriggerType ==
6557
- 'neomail_email_received') ...<Widget>[
6558
- TextField(
6559
- controller: channelController,
6560
- decoration: const InputDecoration(
6561
- labelText: 'Folder (optional)',
6562
- helperText:
6563
- 'Example: INBOX, archive, or a custom folder path.',
6564
- ),
6565
- ),
6566
- const SizedBox(height: 12),
6567
- ],
6568
7035
  if (selectedTriggerType ==
6569
7036
  'outlook_email_received') ...<Widget>[
6570
7037
  TextField(
@@ -6871,13 +7338,44 @@ class _TasksPanelState extends State<TasksPanel> {
6871
7338
  // Manual trigger uses no trigger-specific config.
6872
7339
  } else if (selectedTriggerType == 'schedule') {
6873
7340
  final runAt = runAtController.text.trim();
6874
- triggerConfig['mode'] = runAt.isEmpty
6875
- ? 'recurring'
6876
- : 'one_time';
6877
- if (runAt.isEmpty) {
6878
- triggerConfig['cronExpression'] = cronController.text
7341
+ triggerConfig['mode'] = scheduleDraft.mode;
7342
+ if (scheduleDraft.mode == 'recurring') {
7343
+ scheduleDraft.customCronExpression =
7344
+ customCronController.text.trim();
7345
+ final cronExpression = scheduleDraft.cronExpression
6879
7346
  .trim();
7347
+ if (cronExpression.isEmpty) {
7348
+ ScaffoldMessenger.of(context).showSnackBar(
7349
+ const SnackBar(
7350
+ content: Text('Please choose a schedule.'),
7351
+ backgroundColor: Colors.red,
7352
+ ),
7353
+ );
7354
+ return;
7355
+ }
7356
+ if (scheduleDraft.presetId == 'custom' &&
7357
+ !_looksLikeCronExpression(cronExpression)) {
7358
+ ScaffoldMessenger.of(context).showSnackBar(
7359
+ const SnackBar(
7360
+ content: Text('Custom Cron must have 5 fields.'),
7361
+ backgroundColor: Colors.red,
7362
+ ),
7363
+ );
7364
+ return;
7365
+ }
7366
+ triggerConfig['cronExpression'] = cronExpression;
6880
7367
  } else {
7368
+ if (runAt.isEmpty) {
7369
+ ScaffoldMessenger.of(context).showSnackBar(
7370
+ const SnackBar(
7371
+ content: Text(
7372
+ 'Please enter when the task should run.',
7373
+ ),
7374
+ backgroundColor: Colors.red,
7375
+ ),
7376
+ );
7377
+ return;
7378
+ }
6881
7379
  triggerConfig['runAt'] = runAt;
6882
7380
  }
6883
7381
  } else {
@@ -6918,17 +7416,11 @@ class _TasksPanelState extends State<TasksPanel> {
6918
7416
  triggerConfig['eventTypes'] = eventTypes;
6919
7417
  }
6920
7418
  if (selectedTriggerType == 'gmail_message_received' ||
6921
- selectedTriggerType == 'neomail_email_received' ||
6922
7419
  selectedTriggerType == 'outlook_email_received') {
6923
7420
  if (queryController.text.trim().isNotEmpty) {
6924
7421
  triggerConfig['query'] = queryController.text.trim();
6925
7422
  }
6926
7423
  triggerConfig['unreadOnly'] = unreadOnly;
6927
- if (selectedTriggerType == 'neomail_email_received' &&
6928
- channelController.text.trim().isNotEmpty) {
6929
- triggerConfig['folder'] = channelController.text
6930
- .trim();
6931
- }
6932
7424
  if (selectedTriggerType == 'outlook_email_received' &&
6933
7425
  channelController.text.trim().isNotEmpty) {
6934
7426
  triggerConfig['folderId'] = channelController.text