neoagent 3.0.1-beta.2 → 3.0.1-beta.20

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 (83) hide show
  1. package/.env.example +19 -0
  2. package/README.md +16 -0
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/index.md +1 -0
  7. package/flutter_app/lib/main.dart +4 -1
  8. package/flutter_app/lib/main_app_shell.dart +316 -0
  9. package/flutter_app/lib/main_billing.dart +1465 -0
  10. package/flutter_app/lib/main_chat.dart +1568 -231
  11. package/flutter_app/lib/main_controller.dart +263 -26
  12. package/flutter_app/lib/main_devices.dart +1 -1
  13. package/flutter_app/lib/main_install.dart +1147 -0
  14. package/flutter_app/lib/main_navigation.dart +12 -0
  15. package/flutter_app/lib/main_operations.dart +134 -9
  16. package/flutter_app/lib/main_settings.dart +148 -52
  17. package/flutter_app/lib/main_shared.dart +1 -0
  18. package/flutter_app/lib/src/backend_client.dart +69 -1
  19. package/landing/index.html +2 -2
  20. package/lib/manager.js +156 -0
  21. package/lib/schema_migrations.js +84 -0
  22. package/package.json +10 -4
  23. package/server/admin/access.js +12 -7
  24. package/server/admin/admin.css +78 -0
  25. package/server/admin/admin.js +511 -23
  26. package/server/admin/billing.js +415 -0
  27. package/server/admin/index.html +120 -13
  28. package/server/admin/users.js +15 -15
  29. package/server/db/database.js +13 -21
  30. package/server/db/sessions_db.js +8 -0
  31. package/server/http/middleware.js +4 -4
  32. package/server/http/routes.js +9 -0
  33. package/server/http/static.js +4 -2
  34. package/server/middleware/requireBilling.js +12 -0
  35. package/server/public/.last_build_id +1 -1
  36. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  37. package/server/public/flutter_bootstrap.js +1 -1
  38. package/server/public/main.dart.js +89120 -85431
  39. package/server/routes/admin.js +524 -29
  40. package/server/routes/agents.js +203 -21
  41. package/server/routes/billing.js +127 -0
  42. package/server/routes/billing_webhook.js +43 -0
  43. package/server/routes/memory.js +1 -4
  44. package/server/routes/settings.js +1 -2
  45. package/server/routes/skills.js +1 -1
  46. package/server/routes/triggers.js +2 -2
  47. package/server/services/account/sessions.js +1 -9
  48. package/server/services/ai/loop/agent_engine_core.js +42 -0
  49. package/server/services/ai/loop/blank_recovery.js +36 -0
  50. package/server/services/ai/loop/completion_judge.js +42 -0
  51. package/server/services/ai/loop/conversation_loop.js +248 -34
  52. package/server/services/ai/loop/progress_monitor.js +6 -6
  53. package/server/services/ai/loopPolicy.js +37 -44
  54. package/server/services/ai/messagingFallback.js +25 -1
  55. package/server/services/ai/models.js +18 -0
  56. package/server/services/ai/preModelCompaction.js +25 -5
  57. package/server/services/ai/rate_limits.js +28 -4
  58. package/server/services/ai/systemPrompt.js +23 -5
  59. package/server/services/ai/taskAnalysis.js +2 -0
  60. package/server/services/ai/tools.js +231 -20
  61. package/server/services/billing/billing_email.js +106 -0
  62. package/server/services/billing/config.js +17 -0
  63. package/server/services/billing/plans.js +121 -0
  64. package/server/services/billing/stripe_client.js +21 -0
  65. package/server/services/billing/subscriptions.js +462 -0
  66. package/server/services/billing/trial_guard.js +111 -0
  67. package/server/services/browser/contentExtractor.js +629 -0
  68. package/server/services/browser/controller.js +4 -8
  69. package/server/services/desktop/gateway.js +7 -4
  70. package/server/services/integrations/google/calendar.js +30 -2
  71. package/server/services/integrations/secrets.js +7 -4
  72. package/server/services/manager.js +11 -0
  73. package/server/services/messaging/automation.js +1 -1
  74. package/server/services/messaging/telnyx.js +12 -11
  75. package/server/services/messaging/whatsapp.js +1 -1
  76. package/server/services/recordings/manager.js +13 -7
  77. package/server/services/runtime/backends/local-vm.js +40 -22
  78. package/server/services/tasks/runtime.js +103 -15
  79. package/server/services/tasks/schedule_utils.js +5 -5
  80. package/server/services/voice/runtimeManager.js +19 -10
  81. package/server/services/wearable/gateway.js +8 -5
  82. package/server/services/websocket.js +26 -8
  83. package/server/services/workspace/manager.js +37 -3
@@ -29,6 +29,8 @@ enum AppSection {
29
29
  widgets,
30
30
  mcp,
31
31
  health,
32
+ server,
33
+ billing,
32
34
  }
33
35
 
34
36
  enum SidebarGroup { chat, recordings, automation, settings }
@@ -96,6 +98,10 @@ extension AppSectionX on AppSection {
96
98
  return 'MCP';
97
99
  case AppSection.health:
98
100
  return 'Health';
101
+ case AppSection.server:
102
+ return 'Server';
103
+ case AppSection.billing:
104
+ return 'Billing';
99
105
  }
100
106
  }
101
107
 
@@ -133,6 +139,10 @@ extension AppSectionX on AppSection {
133
139
  return Icons.hub_outlined;
134
140
  case AppSection.health:
135
141
  return Icons.favorite_border;
142
+ case AppSection.server:
143
+ return Icons.dns_outlined;
144
+ case AppSection.billing:
145
+ return Icons.credit_card;
136
146
  }
137
147
  }
138
148
 
@@ -157,6 +167,8 @@ extension AppSectionX on AppSection {
157
167
  case AppSection.accountSettings:
158
168
  case AppSection.messaging:
159
169
  case AppSection.agents:
170
+ case AppSection.server:
171
+ case AppSection.billing:
160
172
  return SidebarGroup.settings;
161
173
  }
162
174
  }
@@ -503,17 +503,30 @@ class _SkillsPanelState extends State<SkillsPanel>
503
503
  late final TabController _tabController;
504
504
  String _selectedCategory = 'all';
505
505
 
506
+ // Installed tab search & filter state
507
+ String _installedQuery = '';
508
+ String _installedStatusFilter = 'all'; // 'all' | 'active' | 'draft' | 'disabled'
509
+ String _installedSourceFilter = 'all'; // 'all' | 'built-in' | 'learned' | 'user' | 'store'
510
+ late final TextEditingController _installedSearchController;
511
+
506
512
  @override
507
513
  void initState() {
508
514
  super.initState();
509
515
  _searchController = TextEditingController();
510
516
  _tabController = TabController(length: 2, vsync: this);
517
+ _installedSearchController = TextEditingController();
518
+ _installedSearchController.addListener(() {
519
+ setState(() {
520
+ _installedQuery = _installedSearchController.text.trim().toLowerCase();
521
+ });
522
+ });
511
523
  }
512
524
 
513
525
  @override
514
526
  void dispose() {
515
527
  _tabController.dispose();
516
528
  _searchController.dispose();
529
+ _installedSearchController.dispose();
517
530
  super.dispose();
518
531
  }
519
532
 
@@ -628,13 +641,122 @@ class _SkillsPanelState extends State<SkillsPanel>
628
641
  );
629
642
  }
630
643
 
644
+ final filteredSkills = controller.skills.where((skill) {
645
+ final q = _installedQuery;
646
+ if (q.isNotEmpty &&
647
+ !skill.name.toLowerCase().contains(q) &&
648
+ !skill.description.toLowerCase().contains(q)) return false;
649
+ if (_installedStatusFilter != 'all') {
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;
653
+ }
654
+ if (_installedSourceFilter != 'all' && skill.source != _installedSourceFilter) return false;
655
+ return true;
656
+ }).toList();
657
+
658
+ final statusFilters = <String>['all', 'active', 'draft', 'disabled'];
659
+ final sourceFilters = <String>['all', 'built-in', 'learned', 'user', 'store'];
660
+
631
661
  return Card(
632
- child: ListView.separated(
633
- padding: const EdgeInsets.all(14),
634
- itemCount: controller.skills.length,
635
- separatorBuilder: (_, __) => const SizedBox(height: 10),
636
- itemBuilder: (context, index) {
637
- final skill = controller.skills[index];
662
+ child: Column(
663
+ children: <Widget>[
664
+ Padding(
665
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 0),
666
+ child: Column(
667
+ crossAxisAlignment: CrossAxisAlignment.start,
668
+ children: <Widget>[
669
+ TextField(
670
+ controller: _installedSearchController,
671
+ decoration: InputDecoration(
672
+ labelText: 'Search by name or description',
673
+ prefixIcon: Icon(Icons.search),
674
+ suffixIcon: _installedSearchController.text.isEmpty
675
+ ? null
676
+ : IconButton(
677
+ onPressed: () {
678
+ _installedSearchController.clear();
679
+ },
680
+ icon: Icon(Icons.close),
681
+ ),
682
+ ),
683
+ ),
684
+ const SizedBox(height: 10),
685
+ SizedBox(
686
+ height: 38,
687
+ child: ListView.separated(
688
+ scrollDirection: Axis.horizontal,
689
+ itemCount: statusFilters.length,
690
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
691
+ itemBuilder: (context, index) {
692
+ final filter = statusFilters[index];
693
+ final selected = filter == _installedStatusFilter;
694
+ return FilterChip(
695
+ selected: selected,
696
+ label: Text(
697
+ filter == 'all'
698
+ ? 'All'
699
+ : filter[0].toUpperCase() + filter.substring(1),
700
+ ),
701
+ selectedColor: _accentMuted,
702
+ checkmarkColor: _accent,
703
+ backgroundColor: _bgSecondary,
704
+ side: BorderSide(color: _border),
705
+ onSelected: (_) =>
706
+ setState(() => _installedStatusFilter = filter),
707
+ );
708
+ },
709
+ ),
710
+ ),
711
+ const SizedBox(height: 8),
712
+ SizedBox(
713
+ height: 38,
714
+ child: ListView.separated(
715
+ scrollDirection: Axis.horizontal,
716
+ itemCount: sourceFilters.length,
717
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
718
+ itemBuilder: (context, index) {
719
+ final filter = sourceFilters[index];
720
+ final selected = filter == _installedSourceFilter;
721
+ return FilterChip(
722
+ selected: selected,
723
+ label: Text(filter == 'all' ? 'All' : filter),
724
+ selectedColor: _accentMuted,
725
+ checkmarkColor: _accent,
726
+ backgroundColor: _bgSecondary,
727
+ side: BorderSide(color: _border),
728
+ onSelected: (_) =>
729
+ setState(() => _installedSourceFilter = filter),
730
+ );
731
+ },
732
+ ),
733
+ ),
734
+ const SizedBox(height: 8),
735
+ Text(
736
+ '${filteredSkills.length} skill${filteredSkills.length == 1 ? '' : 's'}',
737
+ style: TextStyle(color: _textSecondary),
738
+ ),
739
+ const SizedBox(height: 8),
740
+ ],
741
+ ),
742
+ ),
743
+ if (filteredSkills.isEmpty)
744
+ Expanded(
745
+ child: Center(
746
+ child: Text(
747
+ 'No skills match your filters',
748
+ style: TextStyle(color: _textSecondary),
749
+ ),
750
+ ),
751
+ )
752
+ else
753
+ Expanded(
754
+ child: ListView.separated(
755
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
756
+ itemCount: filteredSkills.length,
757
+ separatorBuilder: (_, __) => const SizedBox(height: 10),
758
+ itemBuilder: (context, index) {
759
+ final skill = filteredSkills[index];
638
760
  return LayoutBuilder(
639
761
  builder: (context, constraints) {
640
762
  final compact = constraints.maxWidth < 760;
@@ -780,9 +902,12 @@ class _SkillsPanelState extends State<SkillsPanel>
780
902
  );
781
903
  },
782
904
  );
783
- },
784
- ),
785
- );
905
+ },
906
+ ),
907
+ ),
908
+ ],
909
+ ),
910
+ );
786
911
  }
787
912
 
788
913
  Widget _buildStoreTab(
@@ -1,5 +1,7 @@
1
1
  part of 'main.dart';
2
2
 
3
+ enum _LeaveAction { save, discard, cancel }
4
+
3
5
  class SettingsPanel extends StatefulWidget {
4
6
  const SettingsPanel({
5
7
  super.key,
@@ -190,6 +192,8 @@ class _SettingsPanelState extends State<SettingsPanel> {
190
192
  final Map<String, TextEditingController> _providerBaseUrlControllers =
191
193
  <String, TextEditingController>{};
192
194
 
195
+ bool _hasUnsavedChanges = false;
196
+
193
197
  // Inline runtime test state — ephemeral, not stored in controller.
194
198
  bool _cliTestRunning = false;
195
199
  Map<String, dynamic>? _cliTestResult;
@@ -323,7 +327,22 @@ class _SettingsPanelState extends State<SettingsPanel> {
323
327
  .where((section) => !section.requiresDesktop || _supportsDesktopShell)
324
328
  .toSet();
325
329
 
326
- return ListView(
330
+ return PopScope(
331
+ canPop: !_hasUnsavedChanges,
332
+ onPopInvokedWithResult: (didPop, result) async {
333
+ if (didPop) return;
334
+ final action = await _showLeaveDialog(context);
335
+ if (action == _LeaveAction.save && mounted) {
336
+ await _doSave();
337
+ if (mounted) Navigator.of(context).pop();
338
+ } else if (action == _LeaveAction.discard && mounted) {
339
+ _hydrate();
340
+ setState(() => _hasUnsavedChanges = false);
341
+ Navigator.of(context).pop();
342
+ }
343
+ // cancel: do nothing
344
+ },
345
+ child: ListView(
327
346
  padding: widget.embedded ? EdgeInsets.zero : _pagePadding(context),
328
347
  children: <Widget>[
329
348
  if (!widget.embedded)
@@ -430,6 +449,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
430
449
  ),
431
450
  ],
432
451
  ],
452
+ ),
433
453
  );
434
454
  }
435
455
 
@@ -456,44 +476,44 @@ class _SettingsPanelState extends State<SettingsPanel> {
456
476
  );
457
477
  }
458
478
 
479
+ Future<void> _doSave() async {
480
+ final controller = widget.controller;
481
+ await controller.saveSettings(
482
+ browserBackend: _browserBackend == 'extension' ? 'extension' : 'vm',
483
+ browserExtensionTokenId:
484
+ _browserBackend == 'extension' ? _browserExtensionTokenId : null,
485
+ cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
486
+ cliDesktopDeviceId: _cliDesktopDeviceId,
487
+ smarterSelector: _smarterSelector,
488
+ enabledModels: _enabledModels.toList(),
489
+ defaultChatModel: _defaultChatModel,
490
+ defaultSubagentModel: _defaultSubagentModel,
491
+ defaultRecordingTranscriptionProvider: 'deepgram',
492
+ defaultRecordingTranscriptionModel: _defaultRecordingTranscriptionModel,
493
+ defaultRecordingSummaryProvider: _providerForSelectedModel(
494
+ _defaultRecordingSummaryModel,
495
+ controller.supportedModels,
496
+ ),
497
+ defaultRecordingSummaryModel: _defaultRecordingSummaryModel,
498
+ fallbackModel: _fallbackModel,
499
+ defaultSpeechModel: _defaultSpeechModel,
500
+ voiceSttProvider: controller.voiceSttProvider,
501
+ voiceSttModel: controller.voiceSttModel,
502
+ voiceTtsProvider: controller.voiceTtsProvider,
503
+ voiceTtsModel: controller.voiceTtsModel,
504
+ voiceTtsVoice: controller.voiceTtsVoice,
505
+ voiceRuntimeMode: 'live',
506
+ voiceLiveProvider: _voiceLiveProvider,
507
+ voiceLiveModel: _voiceLiveModel,
508
+ voiceLiveVoice: _voiceLiveVoice,
509
+ aiProviderConfigs: _buildProviderPayload(),
510
+ );
511
+ if (mounted) setState(() => _hasUnsavedChanges = false);
512
+ }
513
+
459
514
  Widget _settingsSaveButton(NeoAgentController controller) {
460
- return FilledButton.icon(
461
- onPressed: controller.isSavingSettings
462
- ? null
463
- : () => controller.saveSettings(
464
- browserBackend: _browserBackend == 'extension'
465
- ? 'extension'
466
- : 'vm',
467
- browserExtensionTokenId: _browserBackend == 'extension'
468
- ? _browserExtensionTokenId
469
- : null,
470
- cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
471
- cliDesktopDeviceId: _cliDesktopDeviceId,
472
- smarterSelector: _smarterSelector,
473
- enabledModels: _enabledModels.toList(),
474
- defaultChatModel: _defaultChatModel,
475
- defaultSubagentModel: _defaultSubagentModel,
476
- defaultRecordingTranscriptionProvider: 'deepgram',
477
- defaultRecordingTranscriptionModel:
478
- _defaultRecordingTranscriptionModel,
479
- defaultRecordingSummaryProvider: _providerForSelectedModel(
480
- _defaultRecordingSummaryModel,
481
- controller.supportedModels,
482
- ),
483
- defaultRecordingSummaryModel: _defaultRecordingSummaryModel,
484
- fallbackModel: _fallbackModel,
485
- defaultSpeechModel: _defaultSpeechModel,
486
- voiceSttProvider: controller.voiceSttProvider,
487
- voiceSttModel: controller.voiceSttModel,
488
- voiceTtsProvider: controller.voiceTtsProvider,
489
- voiceTtsModel: controller.voiceTtsModel,
490
- voiceTtsVoice: controller.voiceTtsVoice,
491
- voiceRuntimeMode: 'live',
492
- voiceLiveProvider: _voiceLiveProvider,
493
- voiceLiveModel: _voiceLiveModel,
494
- voiceLiveVoice: _voiceLiveVoice,
495
- aiProviderConfigs: _buildProviderPayload(),
496
- ),
515
+ final button = FilledButton.icon(
516
+ onPressed: controller.isSavingSettings ? null : _doSave,
497
517
  style: FilledButton.styleFrom(backgroundColor: _accent),
498
518
  icon: controller.isSavingSettings
499
519
  ? const SizedBox.square(
@@ -506,6 +526,19 @@ class _SettingsPanelState extends State<SettingsPanel> {
506
526
  : Icon(Icons.save_outlined),
507
527
  label: Text('Save'),
508
528
  );
529
+ if (!_hasUnsavedChanges) return button;
530
+ return Column(
531
+ crossAxisAlignment: CrossAxisAlignment.end,
532
+ mainAxisSize: MainAxisSize.min,
533
+ children: <Widget>[
534
+ Text(
535
+ 'Unsaved changes',
536
+ style: TextStyle(color: Colors.orange, fontSize: 12),
537
+ ),
538
+ const SizedBox(height: 4),
539
+ button,
540
+ ],
541
+ );
509
542
  }
510
543
 
511
544
  Widget _buildSettingsOverview(
@@ -623,6 +656,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
623
656
  _browserBackend = value;
624
657
  _browserExtensionTokenId ??=
625
658
  controller.selectedBrowserExtensionTokenId;
659
+ _hasUnsavedChanges = true;
626
660
  });
627
661
  }
628
662
  },
@@ -669,7 +703,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
669
703
  }).toList(),
670
704
  onChanged: (value) {
671
705
  if (value != null) {
672
- setState(() => _browserExtensionTokenId = value);
706
+ setState(() {
707
+ _browserExtensionTokenId = value;
708
+ _hasUnsavedChanges = true;
709
+ });
673
710
  }
674
711
  },
675
712
  ),
@@ -746,7 +783,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
746
783
  ],
747
784
  onChanged: (value) {
748
785
  if (value != null) {
749
- setState(() => _cliBackend = value);
786
+ setState(() {
787
+ _cliBackend = value;
788
+ _hasUnsavedChanges = true;
789
+ });
750
790
  }
751
791
  },
752
792
  ),
@@ -785,7 +825,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
785
825
  }).toList(),
786
826
  onChanged: (value) {
787
827
  if (value != null) {
788
- setState(() => _cliDesktopDeviceId = value);
828
+ setState(() {
829
+ _cliDesktopDeviceId = value;
830
+ _hasUnsavedChanges = true;
831
+ });
789
832
  }
790
833
  },
791
834
  ),
@@ -832,7 +875,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
832
875
  subtitle:
833
876
  'Automatically choose the best enabled model for each task type.',
834
877
  value: _smarterSelector,
835
- onChanged: (value) => setState(() => _smarterSelector = value),
878
+ onChanged: (value) => setState(() {
879
+ _smarterSelector = value;
880
+ _hasUnsavedChanges = true;
881
+ }),
836
882
  ),
837
883
  ],
838
884
  ),
@@ -893,7 +939,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
893
939
  options: modelChoices,
894
940
  onChanged: (value) {
895
941
  if (value != null) {
896
- setState(() => _defaultChatModel = value);
942
+ setState(() {
943
+ _defaultChatModel = value;
944
+ _hasUnsavedChanges = true;
945
+ });
897
946
  }
898
947
  },
899
948
  ),
@@ -911,7 +960,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
911
960
  options: modelChoices,
912
961
  onChanged: (value) {
913
962
  if (value != null) {
914
- setState(() => _defaultSubagentModel = value);
963
+ setState(() {
964
+ _defaultSubagentModel = value;
965
+ _hasUnsavedChanges = true;
966
+ });
915
967
  }
916
968
  },
917
969
  ),
@@ -929,7 +981,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
929
981
  options: _modelPickerOptions(routingModels),
930
982
  onChanged: (value) {
931
983
  if (value != null) {
932
- setState(() => _fallbackModel = value);
984
+ setState(() {
985
+ _fallbackModel = value;
986
+ _hasUnsavedChanges = true;
987
+ });
933
988
  }
934
989
  },
935
990
  ),
@@ -977,7 +1032,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
977
1032
  selectedIds: _enabledModels,
978
1033
  ),
979
1034
  );
980
- if (result != null) setState(() => _enabledModels = result);
1035
+ if (result != null) setState(() {
1036
+ _enabledModels = result;
1037
+ _hasUnsavedChanges = true;
1038
+ });
981
1039
  },
982
1040
  ),
983
1041
  ],
@@ -1037,9 +1095,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1037
1095
  options: modelChoices,
1038
1096
  onChanged: (value) {
1039
1097
  if (value != null) {
1040
- setState(
1041
- () => _defaultRecordingSummaryModel = value,
1042
- );
1098
+ setState(() {
1099
+ _defaultRecordingSummaryModel = value;
1100
+ _hasUnsavedChanges = true;
1101
+ });
1043
1102
  }
1044
1103
  },
1045
1104
  ),
@@ -1057,6 +1116,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1057
1116
  if (value != null) {
1058
1117
  setState(() {
1059
1118
  _defaultRecordingTranscriptionModel = value;
1119
+ _hasUnsavedChanges = true;
1060
1120
  });
1061
1121
  }
1062
1122
  },
@@ -1098,7 +1158,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1098
1158
  options: modelChoices,
1099
1159
  onChanged: (value) {
1100
1160
  if (value != null) {
1101
- setState(() => _defaultSpeechModel = value);
1161
+ setState(() {
1162
+ _defaultSpeechModel = value;
1163
+ _hasUnsavedChanges = true;
1164
+ });
1102
1165
  }
1103
1166
  },
1104
1167
  ),
@@ -1158,6 +1221,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
1158
1221
  !voiceOptions.contains(_voiceLiveVoice)) {
1159
1222
  _voiceLiveVoice = voiceOptions.first;
1160
1223
  }
1224
+ _hasUnsavedChanges = true;
1161
1225
  });
1162
1226
  },
1163
1227
  ),
@@ -1174,7 +1238,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1174
1238
  ),
1175
1239
  onChanged: (value) {
1176
1240
  if (value != null) {
1177
- setState(() => _voiceLiveModel = value);
1241
+ setState(() {
1242
+ _voiceLiveModel = value;
1243
+ _hasUnsavedChanges = true;
1244
+ });
1178
1245
  }
1179
1246
  },
1180
1247
  ),
@@ -1189,7 +1256,10 @@ class _SettingsPanelState extends State<SettingsPanel> {
1189
1256
  options: _simplePickerOptions(liveVoiceOptions),
1190
1257
  onChanged: (value) {
1191
1258
  if (value != null) {
1192
- setState(() => _voiceLiveVoice = value);
1259
+ setState(() {
1260
+ _voiceLiveVoice = value;
1261
+ _hasUnsavedChanges = true;
1262
+ });
1193
1263
  }
1194
1264
  },
1195
1265
  ),
@@ -1661,6 +1731,32 @@ class _SettingsPanelState extends State<SettingsPanel> {
1661
1731
  }
1662
1732
  }
1663
1733
 
1734
+ Future<_LeaveAction?> _showLeaveDialog(BuildContext context) {
1735
+ return showDialog<_LeaveAction>(
1736
+ context: context,
1737
+ builder: (ctx) => AlertDialog(
1738
+ title: const Text('Unsaved changes'),
1739
+ content: const Text(
1740
+ 'You have unsaved settings. What would you like to do?',
1741
+ ),
1742
+ actions: <Widget>[
1743
+ TextButton(
1744
+ onPressed: () => Navigator.pop(ctx, _LeaveAction.cancel),
1745
+ child: const Text('Cancel'),
1746
+ ),
1747
+ TextButton(
1748
+ onPressed: () => Navigator.pop(ctx, _LeaveAction.discard),
1749
+ child: const Text('Discard'),
1750
+ ),
1751
+ FilledButton(
1752
+ onPressed: () => Navigator.pop(ctx, _LeaveAction.save),
1753
+ child: const Text('Save'),
1754
+ ),
1755
+ ],
1756
+ ),
1757
+ );
1758
+ }
1759
+
1664
1760
  List<_ModelPickerOption> _recordingTranscriptionOptions(String current) {
1665
1761
  const List<String> defaults = <String>['nova-3', 'nova-2-general'];
1666
1762
  final String normalizedCurrent = current.trim();
@@ -425,6 +425,7 @@ List<AppSection> _mainSections(NeoAgentController controller) {
425
425
  AppSection.memory,
426
426
  if (controller.showHealthSection) AppSection.health,
427
427
  AppSection.settings,
428
+ if (controller.showBillingSection) AppSection.billing,
428
429
  AppSection.runs,
429
430
  AppSection.agents,
430
431
  AppSection.messaging,
@@ -342,6 +342,61 @@ class BackendClient {
342
342
  return deleteMap(baseUrl, '/api/account/sessions/$sessionId');
343
343
  }
344
344
 
345
+ // ── Billing ──────────────────────────────────────────────────────────────
346
+
347
+ Future<Map<String, dynamic>> getBillingPlans(String baseUrl) async {
348
+ return getMap(baseUrl, '/api/billing/plans', allowUnauthorized: true);
349
+ }
350
+
351
+ Future<Map<String, dynamic>> getBillingInfo(String baseUrl) async {
352
+ return getMap(baseUrl, '/api/billing/');
353
+ }
354
+
355
+ Future<Map<String, dynamic>> getBillingInvoices(String baseUrl) async {
356
+ return getMap(baseUrl, '/api/billing/invoices');
357
+ }
358
+
359
+ Future<Map<String, dynamic>> createCheckoutSession({
360
+ required String baseUrl,
361
+ required String planId,
362
+ required String successUrl,
363
+ required String cancelUrl,
364
+ }) async {
365
+ return postMap(baseUrl, '/api/billing/checkout', <String, dynamic>{
366
+ 'planId': planId,
367
+ 'successUrl': successUrl,
368
+ 'cancelUrl': cancelUrl,
369
+ });
370
+ }
371
+
372
+ Future<Map<String, dynamic>> createPortalSession({
373
+ required String baseUrl,
374
+ required String returnUrl,
375
+ }) async {
376
+ return postMap(baseUrl, '/api/billing/portal', <String, dynamic>{
377
+ 'returnUrl': returnUrl,
378
+ });
379
+ }
380
+
381
+ Future<Map<String, dynamic>> cancelBillingSubscription(
382
+ String baseUrl,
383
+ ) async {
384
+ return postMap(baseUrl, '/api/billing/cancel', const <String, dynamic>{});
385
+ }
386
+
387
+ Future<Map<String, dynamic>> startBillingTrial({
388
+ required String baseUrl,
389
+ required String planId,
390
+ String? deviceFingerprint,
391
+ }) async {
392
+ return postMap(baseUrl, '/api/billing/trial', <String, dynamic>{
393
+ 'planId': planId,
394
+ if (deviceFingerprint != null) 'deviceFingerprint': deviceFingerprint,
395
+ });
396
+ }
397
+
398
+ // ── Agent profiles ────────────────────────────────────────────────────────
399
+
345
400
  Future<Map<String, dynamic>> createAgentProfile(
346
401
  String baseUrl,
347
402
  Map<String, dynamic> payload,
@@ -371,10 +426,23 @@ class BackendClient {
371
426
  Future<Map<String, dynamic>> fetchChatHistory(
372
427
  String baseUrl, {
373
428
  String? agentId,
429
+ int limit = 40,
430
+ String? beforeCreatedAt,
431
+ String? beforeSource,
432
+ String? beforeId,
374
433
  }) async {
434
+ final params = <String>[
435
+ 'limit=$limit',
436
+ if (beforeCreatedAt != null && beforeCreatedAt.trim().isNotEmpty)
437
+ 'beforeCreatedAt=${Uri.encodeQueryComponent(beforeCreatedAt.trim())}',
438
+ if (beforeSource != null && beforeSource.trim().isNotEmpty)
439
+ 'beforeSource=${Uri.encodeQueryComponent(beforeSource.trim())}',
440
+ if (beforeId != null && beforeId.trim().isNotEmpty)
441
+ 'beforeId=${Uri.encodeQueryComponent(beforeId.trim())}',
442
+ ];
375
443
  return getMap(
376
444
  baseUrl,
377
- _withAgentQuery('/api/agents/chat-history?limit=120', agentId),
445
+ _withAgentQuery('/api/agents/chat-history?${params.join('&')}', agentId),
378
446
  );
379
447
  }
380
448
 
@@ -910,7 +910,7 @@ p { margin: 0; text-wrap: pretty; }
910
910
  <div class="wrap">
911
911
  <div class="hero-grid">
912
912
  <div class="hero-copy">
913
- <div class="pill"><span class="tag">New</span> <b>NeoAgent is now in private beta</b></div>
913
+ <div class="pill"><span class="tag">New</span> <b>NeoAgent is now in beta</b></div>
914
914
  <h1>
915
915
  <span class="h1-line"><span class="w" style="--i:0">One</span> <span class="w" style="--i:1">agent</span> <span class="w" style="--i:2">for</span> <span class="w" style="--i:3">your</span></span>
916
916
  <span class="h1-line"><span class="w" style="--i:4"><em>whole</em></span> <span class="w" style="--i:5"><em>digital</em></span> <span class="w" style="--i:6"><em>life</em></span></span>
@@ -1194,7 +1194,7 @@ p { margin: 0; text-wrap: pretty; }
1194
1194
  <section class="section-tight" id="cta" data-screen-label="cta">
1195
1195
  <div class="wrap">
1196
1196
  <div class="final reveal">
1197
- <span class="eyebrow" style="display:block;margin-bottom:20px;">Private beta · Limited spots</span>
1197
+ <span class="eyebrow" style="display:block;margin-bottom:20px;">Beta · Limited spots</span>
1198
1198
  <h2>The agent that just <em>handles it.</em></h2>
1199
1199
  <p>Be first to put NeoAgent to work across your devices, channels, and life. We'll send your invite as spots open up.</p>
1200
1200
  <div class="hero-actions" style="justify-content:center;">