neoagent 2.4.4-beta.8 → 2.5.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 (52) hide show
  1. package/.env.example +17 -0
  2. package/README.md +64 -57
  3. package/docs/agent-run-lifecycle.md +65 -0
  4. package/docs/agents-and-users.md +48 -0
  5. package/docs/architecture.md +59 -0
  6. package/docs/automation-architecture.md +50 -0
  7. package/docs/automation.md +54 -70
  8. package/docs/capabilities.md +16 -118
  9. package/docs/clients-and-devices.md +43 -0
  10. package/docs/configuration.md +104 -116
  11. package/docs/development.md +82 -0
  12. package/docs/devices.md +62 -0
  13. package/docs/getting-started.md +47 -57
  14. package/docs/index.md +41 -30
  15. package/docs/integrations-architecture.md +48 -0
  16. package/docs/integrations.md +56 -51
  17. package/docs/memory-architecture.md +90 -0
  18. package/docs/memory.md +78 -0
  19. package/docs/migration.md +12 -5
  20. package/docs/models.md +64 -0
  21. package/docs/operations.md +46 -30
  22. package/docs/persistence.md +49 -0
  23. package/docs/recordings-and-health.md +41 -0
  24. package/docs/runtime-and-tools.md +52 -0
  25. package/docs/security-boundaries.md +65 -111
  26. package/docs/skills.md +32 -45
  27. package/docs/why-neoagent.md +47 -16
  28. package/flutter_app/lib/main_account_settings.dart +79 -15
  29. package/flutter_app/lib/main_chat.dart +155 -8
  30. package/flutter_app/lib/main_controller.dart +38 -4
  31. package/flutter_app/lib/main_devices.dart +9 -3
  32. package/flutter_app/lib/main_models.dart +32 -0
  33. package/landing/index.html +0 -4
  34. package/lib/install_helpers.js +1 -0
  35. package/lib/manager.js +63 -1
  36. package/package.json +2 -2
  37. package/server/admin/admin.js +151 -0
  38. package/server/admin/index.html +55 -3
  39. package/server/public/.last_build_id +1 -1
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +50995 -50850
  42. package/server/routes/account.js +2 -23
  43. package/server/routes/admin.js +18 -2
  44. package/server/routes/agents.js +5 -1
  45. package/server/routes/memory.js +2 -2
  46. package/server/services/account/service_email_settings.js +167 -0
  47. package/server/services/ai/engine.js +2 -17
  48. package/server/services/ai/rate_limits.js +150 -0
  49. package/server/services/ai/tools.js +11 -8
  50. package/server/services/memory/manager.js +0 -3
  51. package/server/services/websocket.js +5 -1
  52. package/docs/supermemory-memory-review.md +0 -852
@@ -1,21 +1,52 @@
1
- # Why NeoAgent
1
+ # NeoAgent, OpenClaw, and Hermes
2
2
 
3
- NeoAgent is the right choice if you want a focused self-hosted agent with real Android device control, recordings, health data, and a practical operator UI. OpenClaw covers more platforms but is a different kind of product.
3
+ This page describes product emphasis, not a universal ranking. The projects
4
+ change quickly, and the right choice depends on the interfaces and operating
5
+ model you need.
4
6
 
5
- This comparison is based on the public [OpenClaw README](https://raw.githubusercontent.com/openclaw/openclaw/main/README.md) as of April 7, 2026 and the current NeoAgent repository.
7
+ The comparison was reviewed on June 14, 2026 against the public
8
+ [OpenClaw repository](https://github.com/openclaw/openclaw),
9
+ [Hermes Agent repository](https://github.com/NousResearch/hermes-agent), and
10
+ the current NeoAgent source.
6
11
 
7
- | | NeoAgent | OpenClaw |
8
- |---|---|---|
9
- | **Best for** | Personal automation server with Android control, recordings, and health data | Broad AI gateway with many channels, nodes, and companion surfaces |
10
- | **Setup** | `npm install -g neoagent && neoagent install` | `openclaw onboard` guided setup |
11
- | **Architecture** | Self-hosted Node server, SQLite, bundled web UI, server-side credentials | Gateway control plane with channels, optional nodes, wider platform surface |
12
- | **Android control** | Full AI control: screenshots, UI dumps, tap, swipe, type, intent launch, APK install, `adb shell` | Android node capabilities via the node ecosystem |
13
- | **Messaging** | 15+ platforms built-in Telegram, WhatsApp, Discord, Slack, Signal, Matrix, iMessage, Teams, IRC, LINE, Mattermost, Telnyx Voice, webhook bridges | Larger channel/node ecosystem |
14
- | **Operator UI** | Chat, runs, logs, tasks, skills, integrations, MCP, memory, devices, recordings, health, settings | Gateway, canvas, platform, node, and channel surfaces |
15
- | **Credentials** | All secrets server-side; channel settings stored through the app | Broader config surface across gateway, channels, and nodes |
16
- | **Automation** | Cron tasks, integration triggers, browser/CLI skills, MCP, subagents, recording search, health summaries, messaging delivery | Cron, webhooks, nodes, channel-specific actions |
17
- | **Recovery** | `neoagent status`, `neoagent logs`, `neoagent update`, `neoagent fix` | Doctor-style diagnostics plus channel/node tooling |
12
+ | Area | NeoAgent | OpenClaw | Hermes Agent |
13
+ |---|---|---|---|
14
+ | Primary experience | Operator UI backed by a self-hosted service | Gateway and assistant across channels, nodes, and companion surfaces | Terminal UI or messaging gateway |
15
+ | Persistent memory | Local structured facts, temporal updates, provenance, hybrid retrieval | Project-defined memory and plugin surfaces | Built-in Markdown memory with optional external memory providers |
16
+ | Android operation | Direct ADB control of a host-attached device or emulator | Android companion/node capabilities | Android and Termux-oriented capabilities |
17
+ | Desktop and browser | Isolated runtime, paired desktop companion, paired Chrome extension | Node and companion ecosystem | Local and remote execution backends |
18
+ | Multi-user administration | User accounts, admin interface, optional service email, per-user runtime | Gateway-centered deployment model | Primarily personal-agent configuration |
19
+ | Automation | Schedules, account events, weather events, webhooks, notification runs | Cron, hooks, webhooks, and channel/node events | Cron, gateway events, and tool-driven workflows |
20
+ | Interfaces | Web, Android, desktop, launcher, messaging, extension, wearable firmware | Broad channel, platform, node, and companion ecosystem | Terminal plus Telegram, Discord, Slack, WhatsApp, Signal, email, and other integrations |
21
+ | Migration into NeoAgent | Native importer target | Skills, memory files, and selected keys can be imported | Skills, memory files, and selected keys can be imported |
18
22
 
19
- **Choose NeoAgent** when you want the shortest path to a self-hosted proactive agent with Android control and a practical operator UI.
23
+ ## Choose NeoAgent when
20
24
 
21
- **Choose OpenClaw** when you need maximum channel coverage, a gateway/node architecture, or the larger companion-app ecosystem.
25
+ - You want a visual operator interface for agents, runs, memory, tasks,
26
+ accounts, devices, and administration.
27
+ - Direct Android control over ADB is central to the deployment.
28
+ - You want structured memory and integration ingestion without a separate
29
+ memory service.
30
+ - You need multiple users or specialist agents with separate state.
31
+ - Launcher mode, Health Connect, recordings, or the bundled device interfaces
32
+ are part of the use case.
33
+
34
+ ## Consider OpenClaw when
35
+
36
+ - Its larger gateway, node, channel, plugin, or companion ecosystem is the main
37
+ requirement.
38
+ - You need an existing OpenClaw-specific integration or community resource.
39
+ - Its distributed assistant architecture better matches your devices.
40
+
41
+ ## Consider Hermes when
42
+
43
+ - A terminal-first workflow is preferable.
44
+ - Its skill ecosystem, remote execution options, or model workflow fits the
45
+ task.
46
+ - Built-in Markdown memory or one of its external memory providers is already
47
+ part of your setup.
48
+
49
+ NeoAgent's memory architecture is documented in [Memory](memory.md). The
50
+ project does not claim superior recall without a reproducible, published
51
+ benchmark that records the dataset, models, retrieval settings, judge, token
52
+ budget, latency, and run artifacts.
@@ -403,7 +403,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
403
403
  controller: _displayNameController,
404
404
  decoration: const InputDecoration(
405
405
  labelText: 'Display name',
406
- helperText: 'Shown in the sidebar. Leave blank to use your username.',
406
+ helperText:
407
+ 'Shown in the sidebar. Leave blank to use your username.',
407
408
  ),
408
409
  ),
409
410
  if (_displayNameInlineError != null) ...<Widget>[
@@ -607,6 +608,16 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
607
608
  return amount.toString();
608
609
  }
609
610
 
611
+ String? _nextUsageDropLabel(DateTime? value) {
612
+ if (value == null) return null;
613
+ final remaining = value.difference(DateTime.now());
614
+ if (remaining.isNegative) return 'Usage updates shortly';
615
+ if (remaining.inHours > 0) {
616
+ return 'Next usage drop in ${remaining.inHours}h ${remaining.inMinutes.remainder(60)}m';
617
+ }
618
+ return 'Next usage drop in ${remaining.inMinutes + 1}m';
619
+ }
620
+
610
621
  Widget _buildUsagePanel() {
611
622
  if (widget.controller.isLoadingAccountSettings) {
612
623
  return const Center(
@@ -616,7 +627,7 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
616
627
  ),
617
628
  );
618
629
  }
619
-
630
+
620
631
  final usage = widget.controller.usageAndLimits;
621
632
  if (usage == null) {
622
633
  return Center(
@@ -639,12 +650,21 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
639
650
  );
640
651
  }
641
652
 
642
- Widget buildStatBox(String label, int current, int? limit, {bool isCustom = false}) {
653
+ Widget buildStatBox(
654
+ String label,
655
+ int current,
656
+ int? limit, {
657
+ required int remaining,
658
+ required bool reached,
659
+ required DateTime? nextDecreaseAt,
660
+ bool isCustom = false,
661
+ }) {
643
662
  final double progress = limit != null
644
663
  ? (limit <= 0 ? 1.0 : (current / limit).clamp(0.0, 1.0))
645
664
  : 0.0;
646
665
  final bool nearLimit = progress > 0.8;
647
- final bool atLimit = progress >= 1.0;
666
+ final bool atLimit = reached || progress >= 1.0;
667
+ final nextDrop = _nextUsageDropLabel(nextDecreaseAt);
648
668
 
649
669
  return Container(
650
670
  width: double.infinity,
@@ -656,8 +676,8 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
656
676
  color: atLimit
657
677
  ? _danger.withValues(alpha: 0.6)
658
678
  : nearLimit
659
- ? _warning.withValues(alpha: 0.5)
660
- : _borderLight,
679
+ ? _warning.withValues(alpha: 0.5)
680
+ : _borderLight,
661
681
  ),
662
682
  ),
663
683
  child: Column(
@@ -665,11 +685,20 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
665
685
  children: <Widget>[
666
686
  Row(
667
687
  children: <Widget>[
668
- Text(label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
688
+ Text(
689
+ label,
690
+ style: const TextStyle(
691
+ fontSize: 16,
692
+ fontWeight: FontWeight.w600,
693
+ ),
694
+ ),
669
695
  const Spacer(),
670
696
  if (limit != null)
671
697
  Container(
672
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
698
+ padding: const EdgeInsets.symmetric(
699
+ horizontal: 8,
700
+ vertical: 3,
701
+ ),
673
702
  decoration: BoxDecoration(
674
703
  color: isCustom
675
704
  ? _warning.withValues(alpha: 0.12)
@@ -696,7 +725,11 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
696
725
  style: TextStyle(
697
726
  fontSize: 28,
698
727
  fontWeight: FontWeight.w800,
699
- color: atLimit ? _danger : nearLimit ? _warning : null,
728
+ color: atLimit
729
+ ? _danger
730
+ : nearLimit
731
+ ? _warning
732
+ : null,
700
733
  ),
701
734
  ),
702
735
  Padding(
@@ -728,16 +761,27 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
728
761
  value: progress,
729
762
  backgroundColor: _border,
730
763
  valueColor: AlwaysStoppedAnimation<Color>(
731
- atLimit ? _danger : nearLimit ? _warning : _accent,
764
+ atLimit
765
+ ? _danger
766
+ : nearLimit
767
+ ? _warning
768
+ : _accent,
732
769
  ),
733
770
  ),
734
771
  ),
735
772
  const SizedBox(height: 6),
736
773
  Text(
737
- '${(progress * 100).toStringAsFixed(0)}% used',
774
+ atLimit
775
+ ? 'Limit reached${nextDrop == null ? '' : ' · $nextDrop'}'
776
+ : '${(progress * 100).toStringAsFixed(0)}% used · ${_formatTokens(remaining)} remaining'
777
+ '${nextDrop == null ? '' : ' · $nextDrop'}',
738
778
  style: TextStyle(
739
779
  fontSize: 11,
740
- color: atLimit ? _danger : nearLimit ? _warning : _textMuted,
780
+ color: atLimit
781
+ ? _danger
782
+ : nearLimit
783
+ ? _warning
784
+ : _textMuted,
741
785
  ),
742
786
  ),
743
787
  ],
@@ -756,9 +800,25 @@ class _AccountSettingsPanelState extends State<AccountSettingsPanel> {
756
800
  style: TextStyle(color: _textSecondary, height: 1.4),
757
801
  ),
758
802
  const SizedBox(height: 24),
759
- buildStatBox('Recent Usage (4 Hours)', usage.fourHourUsage, usage.fourHourLimit, isCustom: usage.fourHourIsCustom),
803
+ buildStatBox(
804
+ 'Recent Usage (4 Hours)',
805
+ usage.fourHourUsage,
806
+ usage.fourHourLimit,
807
+ remaining: usage.fourHourRemaining,
808
+ reached: usage.fourHourReached,
809
+ nextDecreaseAt: usage.fourHourNextDecreaseAt,
810
+ isCustom: usage.fourHourIsCustom,
811
+ ),
760
812
  const SizedBox(height: 16),
761
- buildStatBox('Weekly Usage', usage.weeklyUsage, usage.weeklyLimit, isCustom: usage.weeklyIsCustom),
813
+ buildStatBox(
814
+ 'Weekly Usage',
815
+ usage.weeklyUsage,
816
+ usage.weeklyLimit,
817
+ remaining: usage.weeklyRemaining,
818
+ reached: usage.weeklyReached,
819
+ nextDecreaseAt: usage.weeklyNextDecreaseAt,
820
+ isCustom: usage.weeklyIsCustom,
821
+ ),
762
822
  ],
763
823
  );
764
824
  }
@@ -1128,7 +1188,11 @@ class _AccountSettingsTabs extends StatelessWidget {
1128
1188
  Widget build(BuildContext context) {
1129
1189
  final buttons = <Widget>[
1130
1190
  _tabButton(AccountSettingsTab.account, Icons.person_outline, 'Account'),
1131
- _tabButton(AccountSettingsTab.usage, Icons.data_usage_outlined, 'Usage & Limits'),
1191
+ _tabButton(
1192
+ AccountSettingsTab.usage,
1193
+ Icons.data_usage_outlined,
1194
+ 'Usage & Limits',
1195
+ ),
1132
1196
  _tabButton(
1133
1197
  AccountSettingsTab.security,
1134
1198
  Icons.security_outlined,
@@ -295,12 +295,13 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
295
295
  transitionBuilder: (ctx, animation, secondary, child) => FadeTransition(
296
296
  opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
297
297
  child: SlideTransition(
298
- position: Tween<Offset>(
299
- begin: const Offset(0, 0.04),
300
- end: Offset.zero,
301
- ).animate(
302
- CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
303
- ),
298
+ position:
299
+ Tween<Offset>(
300
+ begin: const Offset(0, 0.04),
301
+ end: Offset.zero,
302
+ ).animate(
303
+ CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
304
+ ),
304
305
  child: child,
305
306
  ),
306
307
  ),
@@ -398,6 +399,11 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
398
399
  _maybeFollowChatContent(messages, controller);
399
400
 
400
401
  final threadChildren = <Widget>[
402
+ if (controller.usageAndLimits case final usage?
403
+ when usage.hasLimits) ...<Widget>[
404
+ _RateLimitStatusCard(usage: usage),
405
+ const SizedBox(height: 16),
406
+ ],
401
407
  if (controller.errorMessage != null) ...<Widget>[
402
408
  _InlineError(message: controller.errorMessage!),
403
409
  const SizedBox(height: 16),
@@ -471,8 +477,12 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
471
477
  SelectionArea(
472
478
  child: ListView(
473
479
  controller: _scrollController,
474
- padding:
475
- EdgeInsets.fromLTRB(sidePadding, 30, sidePadding, 18),
480
+ padding: EdgeInsets.fromLTRB(
481
+ sidePadding,
482
+ 30,
483
+ sidePadding,
484
+ 18,
485
+ ),
476
486
  children: <Widget>[
477
487
  Center(
478
488
  child: ConstrainedBox(
@@ -715,6 +725,143 @@ class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
715
725
  }
716
726
  }
717
727
 
728
+ class _RateLimitStatusCard extends StatelessWidget {
729
+ const _RateLimitStatusCard({required this.usage});
730
+
731
+ final AccountUsageAndLimits usage;
732
+
733
+ String _formatTokens(int amount) {
734
+ if (amount >= 1000000) {
735
+ final value = amount / 1000000;
736
+ return '${value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1)}M';
737
+ }
738
+ if (amount >= 1000) {
739
+ final value = amount / 1000;
740
+ return '${value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1)}k';
741
+ }
742
+ return amount.toString();
743
+ }
744
+
745
+ String? _nextDropLabel(DateTime? value) {
746
+ if (value == null) return null;
747
+ final remaining = value.difference(DateTime.now());
748
+ if (remaining.isNegative) return 'Usage updates shortly';
749
+ if (remaining.inHours > 0) {
750
+ return 'Next usage drop in ${remaining.inHours}h ${remaining.inMinutes.remainder(60)}m';
751
+ }
752
+ return 'Next usage drop in ${remaining.inMinutes + 1}m';
753
+ }
754
+
755
+ Widget _buildWindow({
756
+ required String label,
757
+ required int usageAmount,
758
+ required int? limit,
759
+ required int remaining,
760
+ required bool reached,
761
+ required DateTime? nextDecreaseAt,
762
+ }) {
763
+ if (limit == null || limit <= 0) return const SizedBox.shrink();
764
+ final progress = (usageAmount / limit).clamp(0.0, 1.0);
765
+ final color = reached
766
+ ? _danger
767
+ : progress >= 0.8
768
+ ? _warning
769
+ : _accent;
770
+ final nextDrop = _nextDropLabel(nextDecreaseAt);
771
+ return Column(
772
+ crossAxisAlignment: CrossAxisAlignment.start,
773
+ children: <Widget>[
774
+ Row(
775
+ children: <Widget>[
776
+ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
777
+ const Spacer(),
778
+ Text(
779
+ reached ? 'Limit reached' : '${_formatTokens(remaining)} left',
780
+ style: TextStyle(
781
+ color: color,
782
+ fontSize: 12,
783
+ fontWeight: FontWeight.w600,
784
+ ),
785
+ ),
786
+ ],
787
+ ),
788
+ const SizedBox(height: 8),
789
+ ClipRRect(
790
+ borderRadius: BorderRadius.circular(999),
791
+ child: LinearProgressIndicator(
792
+ minHeight: 7,
793
+ value: progress,
794
+ backgroundColor: _border,
795
+ valueColor: AlwaysStoppedAnimation<Color>(color),
796
+ ),
797
+ ),
798
+ const SizedBox(height: 6),
799
+ Text(
800
+ '${_formatTokens(usageAmount)} / ${_formatTokens(limit)} tokens'
801
+ '${nextDrop == null ? '' : ' · $nextDrop'}',
802
+ style: TextStyle(color: _textMuted, fontSize: 11),
803
+ ),
804
+ ],
805
+ );
806
+ }
807
+
808
+ @override
809
+ Widget build(BuildContext context) {
810
+ final windows = <Widget>[
811
+ _buildWindow(
812
+ label: '4-hour usage',
813
+ usageAmount: usage.fourHourUsage,
814
+ limit: usage.fourHourLimit,
815
+ remaining: usage.fourHourRemaining,
816
+ reached: usage.fourHourReached,
817
+ nextDecreaseAt: usage.fourHourNextDecreaseAt,
818
+ ),
819
+ _buildWindow(
820
+ label: '7-day usage',
821
+ usageAmount: usage.weeklyUsage,
822
+ limit: usage.weeklyLimit,
823
+ remaining: usage.weeklyRemaining,
824
+ reached: usage.weeklyReached,
825
+ nextDecreaseAt: usage.weeklyNextDecreaseAt,
826
+ ),
827
+ ];
828
+ return Container(
829
+ width: double.infinity,
830
+ padding: const EdgeInsets.all(14),
831
+ decoration: BoxDecoration(
832
+ color: usage.isReached ? _danger.withValues(alpha: 0.08) : _bgSecondary,
833
+ borderRadius: BorderRadius.circular(16),
834
+ border: Border.all(
835
+ color: usage.isReached
836
+ ? _danger.withValues(alpha: 0.55)
837
+ : _borderLight,
838
+ ),
839
+ ),
840
+ child: LayoutBuilder(
841
+ builder: (context, constraints) {
842
+ if (constraints.maxWidth < 620) {
843
+ return Column(
844
+ children: <Widget>[
845
+ windows.first,
846
+ const SizedBox(height: 14),
847
+ windows.last,
848
+ ],
849
+ );
850
+ }
851
+ return Row(
852
+ crossAxisAlignment: CrossAxisAlignment.start,
853
+ children: <Widget>[
854
+ Expanded(child: windows.first),
855
+ const SizedBox(width: 24),
856
+ Expanded(child: windows.last),
857
+ ],
858
+ );
859
+ },
860
+ ),
861
+ );
862
+ }
863
+ }
864
+
718
865
  double _chatSidePadding(BuildContext context) {
719
866
  final width = MediaQuery.sizeOf(context).width;
720
867
  if (width >= 1280) return 40;
@@ -2033,6 +2033,11 @@ class NeoAgentController extends ChangeNotifier {
2033
2033
  _backendClient.fetchTokenUsageSummary(backendUrl, agentId: agentId),
2034
2034
  const <String, dynamic>{},
2035
2035
  );
2036
+ final rateLimitFuture = _softRefreshLoad<Map<String, dynamic>>(
2037
+ 'rate_limits',
2038
+ _backendClient.fetchAccountUsage(backendUrl),
2039
+ const <String, dynamic>{},
2040
+ );
2036
2041
  final updateFuture = _backendClient
2037
2042
  .fetchUpdateStatus(backendUrl)
2038
2043
  .catchError((_) => const <String, dynamic>{});
@@ -2142,6 +2147,7 @@ class NeoAgentController extends ChangeNotifier {
2142
2147
  final runsResponse = await runsFuture;
2143
2148
  final versionResponse = await versionFuture;
2144
2149
  final tokenResponse = await tokenFuture;
2150
+ final rateLimitResponse = await rateLimitFuture;
2145
2151
  final updateResponse = await updateFuture;
2146
2152
  final messagingResponse = await messagingFuture;
2147
2153
  final messagingMessagesResponse = await messagingMessagesFuture;
@@ -2193,6 +2199,7 @@ class NeoAgentController extends ChangeNotifier {
2193
2199
  versionInfo = versionResponse;
2194
2200
  backendHealthStatus = healthResponse;
2195
2201
  tokenUsage = TokenUsageSnapshot.fromJson(tokenResponse);
2202
+ usageAndLimits = AccountUsageAndLimits.fromJson(rateLimitResponse);
2196
2203
  updateStatus = UpdateStatusSnapshot.fromJson(updateResponse);
2197
2204
  messagingStatuses = messagingResponse.map(
2198
2205
  (key, value) => MapEntry(
@@ -4640,20 +4647,24 @@ class NeoAgentController extends ChangeNotifier {
4640
4647
  }
4641
4648
  activeRun = null;
4642
4649
  await refreshRunsOnly();
4650
+ await refreshRateLimitUsage();
4643
4651
  } catch (error) {
4652
+ final friendlyError = _friendlyErrorMessage(error);
4644
4653
  chatMessages = <ChatEntry>[
4645
4654
  ...chatMessages,
4646
4655
  ChatEntry(
4647
4656
  id: '',
4648
4657
  role: 'assistant',
4649
- content:
4650
- 'I could not complete that request right now. Please try again in a moment.',
4658
+ content: friendlyError,
4651
4659
  platform: 'flutter',
4652
4660
  createdAt: DateTime.now(),
4653
4661
  ),
4654
4662
  ];
4655
4663
  activeRun = null;
4656
- errorMessage = _friendlyErrorMessage(error);
4664
+ errorMessage = friendlyError;
4665
+ if (error is BackendException && error.statusCode == 429) {
4666
+ await refreshRateLimitUsage();
4667
+ }
4657
4668
  } finally {
4658
4669
  if (_socket == null || !socketConnected) {
4659
4670
  isSendingMessage = false;
@@ -4786,6 +4797,16 @@ class NeoAgentController extends ChangeNotifier {
4786
4797
  }
4787
4798
  }
4788
4799
 
4800
+ Future<void> refreshRateLimitUsage() async {
4801
+ if (!isAuthenticated) return;
4802
+ try {
4803
+ usageAndLimits = AccountUsageAndLimits.fromJson(
4804
+ await _backendClient.fetchAccountUsage(backendUrl),
4805
+ );
4806
+ notifyListeners();
4807
+ } catch (_) {}
4808
+ }
4809
+
4789
4810
  Future<bool> updateAccountEmail({
4790
4811
  required String email,
4791
4812
  required String currentPassword,
@@ -7292,11 +7313,13 @@ class NeoAgentController extends ChangeNotifier {
7292
7313
  final payload = _jsonMap(data);
7293
7314
  final runId = payload['runId']?.toString() ?? '';
7294
7315
  if (_voiceRunIds.remove(runId)) {
7316
+ unawaited(refreshRateLimitUsage());
7295
7317
  return;
7296
7318
  }
7297
7319
  if (_backgroundRunIds.remove(runId)) {
7298
7320
  unawaited(refreshRunsOnly());
7299
7321
  unawaited(refreshMemory());
7322
+ unawaited(refreshRateLimitUsage());
7300
7323
  notifyListeners();
7301
7324
  return;
7302
7325
  }
@@ -7320,6 +7343,7 @@ class NeoAgentController extends ChangeNotifier {
7320
7343
  );
7321
7344
  }
7322
7345
  unawaited(refreshRunsOnly());
7346
+ unawaited(refreshRateLimitUsage());
7323
7347
  notifyListeners();
7324
7348
  });
7325
7349
  socket.on('run:stopped', (dynamic data) {
@@ -7370,8 +7394,18 @@ class NeoAgentController extends ChangeNotifier {
7370
7394
  streamingAssistant = '';
7371
7395
  activeRun = null;
7372
7396
  isSendingMessage = false;
7373
- errorMessage =
7397
+ final message =
7398
+ payload['error']?.toString().trim() ??
7374
7399
  'I could not complete that request right now. Please try again in a moment.';
7400
+ errorMessage = _friendlyErrorMessage(
7401
+ BackendException(
7402
+ message,
7403
+ statusCode: payload['code'] == 'RATE_LIMIT_EXCEEDED' ? 429 : null,
7404
+ ),
7405
+ );
7406
+ if (payload['code'] == 'RATE_LIMIT_EXCEEDED') {
7407
+ unawaited(refreshRateLimitUsage());
7408
+ }
7375
7409
  notifyListeners();
7376
7410
  });
7377
7411
  socket.on('tasks:task_complete', (dynamic _) {
@@ -1734,6 +1734,14 @@ class _InteractiveSurfacePreviewState
1734
1734
  Widget build(BuildContext context) {
1735
1735
  final path = widget.screenshotPath;
1736
1736
  final hasImage = path != null && path.isNotEmpty;
1737
+ final viewportHeight = MediaQuery.sizeOf(context).height;
1738
+ final surfaceHeightCap = widget.surface == _DeviceSurface.android
1739
+ ? 640.0
1740
+ : 560.0;
1741
+ final previewMaxHeight = math.min(
1742
+ surfaceHeightCap,
1743
+ viewportHeight * 0.48,
1744
+ );
1737
1745
  final aspectRatio = switch (widget.surface) {
1738
1746
  _DeviceSurface.browser => 16 / 10,
1739
1747
  _DeviceSurface.android => 10 / 16,
@@ -1755,9 +1763,7 @@ class _InteractiveSurfacePreviewState
1755
1763
  child: Column(
1756
1764
  children: <Widget>[
1757
1765
  ConstrainedBox(
1758
- constraints: BoxConstraints(
1759
- maxHeight: widget.surface == _DeviceSurface.android ? 640 : 560,
1760
- ),
1766
+ constraints: BoxConstraints(maxHeight: previewMaxHeight),
1761
1767
  child: AspectRatio(
1762
1768
  aspectRatio: aspectRatio,
1763
1769
  child: ClipRRect(
@@ -4111,6 +4111,12 @@ class AccountUsageAndLimits {
4111
4111
  this.weeklyLimit,
4112
4112
  this.fourHourUsage = 0,
4113
4113
  this.weeklyUsage = 0,
4114
+ this.fourHourRemaining = 0,
4115
+ this.weeklyRemaining = 0,
4116
+ this.fourHourReached = false,
4117
+ this.weeklyReached = false,
4118
+ this.fourHourNextDecreaseAt,
4119
+ this.weeklyNextDecreaseAt,
4114
4120
  this.fourHourIsCustom = false,
4115
4121
  this.weeklyIsCustom = false,
4116
4122
  });
@@ -4118,11 +4124,28 @@ class AccountUsageAndLimits {
4118
4124
  factory AccountUsageAndLimits.fromJson(Map<String, dynamic> json) {
4119
4125
  final limits = json['limits'] is Map ? json['limits'] as Map : const {};
4120
4126
  final usage = json['usage'] is Map ? json['usage'] as Map : const {};
4127
+ final remaining = json['remaining'] is Map
4128
+ ? json['remaining'] as Map
4129
+ : const {};
4130
+ final reached = json['reached'] is Map ? json['reached'] as Map : const {};
4131
+ final nextDecreaseAt = json['nextDecreaseAt'] is Map
4132
+ ? json['nextDecreaseAt'] as Map
4133
+ : const {};
4121
4134
  return AccountUsageAndLimits(
4122
4135
  fourHourLimit: int.tryParse(limits['fourHour']?.toString() ?? ''),
4123
4136
  weeklyLimit: int.tryParse(limits['weekly']?.toString() ?? ''),
4124
4137
  fourHourUsage: _asInt(usage['fourHour']),
4125
4138
  weeklyUsage: _asInt(usage['weekly']),
4139
+ fourHourRemaining: _asInt(remaining['fourHour']),
4140
+ weeklyRemaining: _asInt(remaining['weekly']),
4141
+ fourHourReached: reached['fourHour'] == true,
4142
+ weeklyReached: reached['weekly'] == true,
4143
+ fourHourNextDecreaseAt: DateTime.tryParse(
4144
+ nextDecreaseAt['fourHour']?.toString() ?? '',
4145
+ ),
4146
+ weeklyNextDecreaseAt: DateTime.tryParse(
4147
+ nextDecreaseAt['weekly']?.toString() ?? '',
4148
+ ),
4126
4149
  fourHourIsCustom: limits['fourHourIsCustom'] == true,
4127
4150
  weeklyIsCustom: limits['weeklyIsCustom'] == true,
4128
4151
  );
@@ -4132,6 +4155,15 @@ class AccountUsageAndLimits {
4132
4155
  final int? weeklyLimit;
4133
4156
  final int fourHourUsage;
4134
4157
  final int weeklyUsage;
4158
+ final int fourHourRemaining;
4159
+ final int weeklyRemaining;
4160
+ final bool fourHourReached;
4161
+ final bool weeklyReached;
4162
+ final DateTime? fourHourNextDecreaseAt;
4163
+ final DateTime? weeklyNextDecreaseAt;
4135
4164
  final bool fourHourIsCustom;
4136
4165
  final bool weeklyIsCustom;
4166
+
4167
+ bool get hasLimits => fourHourLimit != null || weeklyLimit != null;
4168
+ bool get isReached => fourHourReached || weeklyReached;
4137
4169
  }
@@ -890,7 +890,6 @@ p { margin: 0; text-wrap: pretty; }
890
890
  </nav>
891
891
  <div class="nav-right">
892
892
  <a class="signin" href="/app">Sign in</a>
893
- <a class="btn btn-primary" href="#cta">Get early access</a>
894
893
  </div>
895
894
  <button class="nav-toggle" aria-label="Menu">
896
895
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M3 12h18M3 18h18" stroke-linecap="round"></path></svg>
@@ -900,7 +899,6 @@ p { margin: 0; text-wrap: pretty; }
900
899
  <a href="#features">Features</a>
901
900
  <a href="#channels">Channels</a>
902
901
  <a href="#how">How it works</a>
903
- <a class="btn btn-primary" href="#cta">Get early access</a>
904
902
  </div>
905
903
  </header>
906
904
 
@@ -919,7 +917,6 @@ p { margin: 0; text-wrap: pretty; }
919
917
  </h1>
920
918
  <p class="lede">NeoAgent watches, listens, and acts across your devices, browser, voice, and messaging — one operator that quietly handles your digital world.</p>
921
919
  <div class="hero-actions">
922
- <a class="btn btn-primary btn-lg" href="#cta">Get early access</a>
923
920
  <a class="btn btn-soft btn-lg" href="#features">Explore features
924
921
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"></path></svg>
925
922
  </a>
@@ -1201,7 +1198,6 @@ p { margin: 0; text-wrap: pretty; }
1201
1198
  <h2>The agent that just <em>handles it.</em></h2>
1202
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>
1203
1200
  <div class="hero-actions" style="justify-content:center;">
1204
- <a class="btn btn-primary btn-lg" href="#">Get early access</a>
1205
1201
  <a class="btn btn-soft btn-lg" href="#features">Explore features</a>
1206
1202
  </div>
1207
1203
  </div>