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
@@ -233,8 +233,11 @@ void _openOfficialIntegrationSetupDialog(
233
233
  case 'home_assistant':
234
234
  _showHomeAssistantSetupDialog(context, controller);
235
235
  return;
236
- case 'neomail':
237
- _showNeoMailSetupDialog(context, controller);
236
+ case 'neoarchive':
237
+ _showNeoArchiveSetupDialog(context, controller);
238
+ return;
239
+ case 'neorecall':
240
+ _showNeoRecallSetupDialog(context, controller);
238
241
  return;
239
242
  case 'trello':
240
243
  _showTrelloSetupDialog(context, controller);
@@ -242,13 +245,13 @@ void _openOfficialIntegrationSetupDialog(
242
245
  }
243
246
  }
244
247
 
245
- Future<void> _showNeoMailSetupDialog(
248
+ Future<void> _showNeoRecallSetupDialog(
246
249
  BuildContext context,
247
250
  NeoAgentController controller,
248
251
  ) async {
249
252
  Map<String, dynamic> existing;
250
253
  try {
251
- existing = await controller.getOfficialIntegrationConfig('neomail');
254
+ existing = await controller.getOfficialIntegrationConfig('neorecall');
252
255
  } catch (error) {
253
256
  if (context.mounted) {
254
257
  ScaffoldMessenger.of(context).showSnackBar(
@@ -257,57 +260,241 @@ Future<void> _showNeoMailSetupDialog(
257
260
  }
258
261
  return;
259
262
  }
260
-
261
263
  final savedBaseUrl = existing['baseUrl']?.toString() ?? '';
262
264
  final accountCount = (existing['accountCount'] as num?)?.toInt() ?? 0;
263
- final hasConnectedAccount =
264
- existing['hasConnectedAccount'] == true || accountCount > 0;
265
- var formError = '';
265
+ final connected = existing['hasConnectedAccount'] == true || accountCount > 0;
266
+ final baseUrlController = TextEditingController(text: savedBaseUrl);
267
+ var errorText = '';
266
268
  var busy = false;
267
269
 
270
+ Future<void> save(
271
+ StateSetter setState,
272
+ BuildContext dialogContext, {
273
+ required bool connect,
274
+ }) async {
275
+ setState(() {
276
+ errorText = '';
277
+ busy = true;
278
+ });
279
+ try {
280
+ final baseUrl = baseUrlController.text.trim();
281
+ if (baseUrl.isEmpty) {
282
+ setState(() {
283
+ errorText = 'NeoRecall backend URL is required.';
284
+ busy = false;
285
+ });
286
+ return;
287
+ }
288
+ await controller.saveOfficialIntegrationConfig(
289
+ 'neorecall',
290
+ config: <String, dynamic>{'baseUrl': baseUrl},
291
+ );
292
+ if (connect) {
293
+ await controller.connectOfficialIntegration(
294
+ 'neorecall',
295
+ appId: 'recall',
296
+ );
297
+ if ((controller.errorMessage ?? '').trim().isNotEmpty) {
298
+ setState(() {
299
+ errorText = controller.errorMessage!;
300
+ busy = false;
301
+ });
302
+ return;
303
+ }
304
+ }
305
+ if (dialogContext.mounted) Navigator.of(dialogContext).pop();
306
+ } catch (_) {
307
+ setState(() {
308
+ errorText = controller.errorMessage ?? 'Could not save NeoRecall setup.';
309
+ busy = false;
310
+ });
311
+ }
312
+ }
313
+
314
+ if (!context.mounted) return;
315
+ await showDialog<void>(
316
+ context: context,
317
+ barrierDismissible: false,
318
+ builder: (dialogContext) => StatefulBuilder(
319
+ builder: (dialogContext, setState) => AlertDialog(
320
+ title: const Text('NeoRecall Setup'),
321
+ content: SizedBox(
322
+ width: 540,
323
+ child: Column(
324
+ mainAxisSize: MainAxisSize.min,
325
+ crossAxisAlignment: CrossAxisAlignment.start,
326
+ children: <Widget>[
327
+ Text(
328
+ 'Connect your self-hosted NeoRecall server. NeoAgent receives read-only access to local search, memories, and transcript evidence after you approve the OAuth screen.',
329
+ style: TextStyle(color: _textSecondary),
330
+ ),
331
+ const SizedBox(height: 16),
332
+ const _IntegrationSetupStatusItem(
333
+ label: 'Connection Method',
334
+ status: 'OAuth with PKCE',
335
+ isConnected: true,
336
+ ),
337
+ const SizedBox(height: 12),
338
+ _IntegrationSetupStatusItem(
339
+ label: 'Connected NeoRecall User',
340
+ status: connected
341
+ ? '$accountCount ${accountCount == 1 ? 'connected user' : 'connected users'}'
342
+ : 'Not connected',
343
+ isConnected: connected,
344
+ ),
345
+ const SizedBox(height: 12),
346
+ TextField(
347
+ controller: baseUrlController,
348
+ keyboardType: TextInputType.url,
349
+ decoration: const InputDecoration(
350
+ labelText: 'NeoRecall Backend URL',
351
+ hintText: 'https://recall.example.com',
352
+ border: OutlineInputBorder(),
353
+ ),
354
+ ),
355
+ const SizedBox(height: 8),
356
+ Text(
357
+ 'Local and private-network URLs are supported when the NeoAgent server can reach them. Audio is never exposed to NeoAgent.',
358
+ style: TextStyle(color: _textSecondary, fontSize: 12),
359
+ ),
360
+ if (errorText.isNotEmpty) ...<Widget>[
361
+ const SizedBox(height: 12),
362
+ Text(errorText, style: TextStyle(color: _danger, fontSize: 12)),
363
+ ],
364
+ ],
365
+ ),
366
+ ),
367
+ actions: <Widget>[
368
+ if (savedBaseUrl.isNotEmpty)
369
+ TextButton(
370
+ onPressed: busy
371
+ ? null
372
+ : () async {
373
+ final confirm =
374
+ await showDialog<bool>(
375
+ context: dialogContext,
376
+ builder: (context) => AlertDialog(
377
+ title: const Text('Disconnect NeoRecall?'),
378
+ content: const Text(
379
+ 'This removes the NeoRecall backend URL and all connected NeoRecall accounts for this agent.',
380
+ ),
381
+ actions: <Widget>[
382
+ TextButton(
383
+ onPressed: () => Navigator.of(context).pop(false),
384
+ child: const Text('Cancel'),
385
+ ),
386
+ FilledButton(
387
+ onPressed: () => Navigator.of(context).pop(true),
388
+ child: const Text('Disconnect'),
389
+ ),
390
+ ],
391
+ ),
392
+ ) ??
393
+ false;
394
+ if (!confirm) return;
395
+ setState(() {
396
+ busy = true;
397
+ errorText = '';
398
+ });
399
+ try {
400
+ await controller.clearOfficialIntegrationConfig('neorecall');
401
+ if (dialogContext.mounted) Navigator.of(dialogContext).pop();
402
+ } catch (_) {
403
+ setState(() {
404
+ errorText = controller.errorMessage ?? 'Could not disconnect NeoRecall.';
405
+ busy = false;
406
+ });
407
+ }
408
+ },
409
+ child: const Text('Disconnect'),
410
+ ),
411
+ TextButton(
412
+ onPressed: busy ? null : () => Navigator.of(dialogContext).pop(),
413
+ child: const Text('Close'),
414
+ ),
415
+ TextButton(
416
+ onPressed: busy ? null : () => save(setState, dialogContext, connect: false),
417
+ child: const Text('Save Only'),
418
+ ),
419
+ FilledButton(
420
+ onPressed: busy ? null : () => save(setState, dialogContext, connect: true),
421
+ child: Text(
422
+ busy
423
+ ? 'Working...'
424
+ : connected
425
+ ? 'Connect Another Account'
426
+ : 'Save & Connect',
427
+ ),
428
+ ),
429
+ ],
430
+ ),
431
+ ),
432
+ );
433
+ baseUrlController.dispose();
434
+ }
435
+
436
+ Future<void> _showNeoArchiveSetupDialog(
437
+ BuildContext context,
438
+ NeoAgentController controller,
439
+ ) async {
440
+ Map<String, dynamic> existing;
441
+ try {
442
+ existing = await controller.getOfficialIntegrationConfig('neoarchive');
443
+ } catch (error) {
444
+ if (context.mounted) {
445
+ ScaffoldMessenger.of(context).showSnackBar(
446
+ SnackBar(content: Text(controller.errorMessage ?? error.toString())),
447
+ );
448
+ }
449
+ return;
450
+ }
451
+ final savedBaseUrl = existing['baseUrl']?.toString() ?? '';
452
+ final accountCount = (existing['accountCount'] as num?)?.toInt() ?? 0;
453
+ final connected = existing['hasConnectedAccount'] == true || accountCount > 0;
268
454
  final baseUrlController = TextEditingController(text: savedBaseUrl);
455
+ var errorText = '';
456
+ var busy = false;
269
457
 
270
- Future<void> saveConfig(
271
- StateSetter setState, {
272
- required BuildContext dialogContext,
273
- required bool connectAfterSave,
458
+ Future<void> save(
459
+ StateSetter setState,
460
+ BuildContext dialogContext, {
461
+ required bool connect,
274
462
  }) async {
275
463
  setState(() {
276
- formError = '';
464
+ errorText = '';
277
465
  busy = true;
278
466
  });
279
467
  try {
280
468
  final baseUrl = baseUrlController.text.trim();
281
469
  if (baseUrl.isEmpty) {
282
470
  setState(() {
283
- formError = 'NeoMail backend URL is required.';
471
+ errorText = 'NeoArchive backend URL is required.';
284
472
  busy = false;
285
473
  });
286
474
  return;
287
475
  }
288
476
  await controller.saveOfficialIntegrationConfig(
289
- 'neomail',
477
+ 'neoarchive',
290
478
  config: <String, dynamic>{'baseUrl': baseUrl},
291
479
  );
292
- if (connectAfterSave) {
480
+ if (connect) {
293
481
  await controller.connectOfficialIntegration(
294
- 'neomail',
295
- appId: 'mailbox',
482
+ 'neoarchive',
483
+ appId: 'archive',
296
484
  );
297
485
  if ((controller.errorMessage ?? '').trim().isNotEmpty) {
298
486
  setState(() {
299
- formError = controller.errorMessage!;
487
+ errorText = controller.errorMessage!;
300
488
  busy = false;
301
489
  });
302
490
  return;
303
491
  }
304
492
  }
305
- if (dialogContext.mounted) {
306
- Navigator.of(dialogContext).pop();
307
- }
493
+ if (dialogContext.mounted) Navigator.of(dialogContext).pop();
308
494
  } catch (_) {
309
495
  setState(() {
310
- formError = controller.errorMessage ?? 'Could not save NeoMail setup.';
496
+ errorText =
497
+ controller.errorMessage ?? 'Could not save NeoArchive setup.';
311
498
  busy = false;
312
499
  });
313
500
  }
@@ -317,175 +504,134 @@ Future<void> _showNeoMailSetupDialog(
317
504
  await showDialog<void>(
318
505
  context: context,
319
506
  barrierDismissible: false,
320
- builder: (dialogContext) {
321
- return StatefulBuilder(
322
- builder: (dialogContext, setState) {
323
- return AlertDialog(
324
- title: const Text('NeoMail Setup'),
325
- content: SizedBox(
326
- width: 540,
327
- child: Column(
328
- mainAxisSize: MainAxisSize.min,
329
- crossAxisAlignment: CrossAxisAlignment.start,
330
- children: <Widget>[
331
- Text(
332
- 'Add the NeoMail backend URL once. NeoAgent will then open NeoMail OAuth so the user can sign in and approve mailbox access without API keys.',
333
- style: TextStyle(color: _textSecondary),
334
- ),
335
- const SizedBox(height: 16),
336
- const _IntegrationSetupStatusItem(
337
- label: 'Connection Method',
338
- status: 'OAuth companion flow',
339
- isConnected: true,
340
- ),
341
- const SizedBox(height: 12),
342
- _IntegrationSetupStatusItem(
343
- label: 'Backend URL',
344
- status: savedBaseUrl.trim().isNotEmpty
345
- ? 'Configured'
346
- : 'Not configured',
347
- isConnected: savedBaseUrl.trim().isNotEmpty,
348
- ),
349
- const SizedBox(height: 12),
350
- _IntegrationSetupStatusItem(
351
- label: 'Connected NeoMail User',
352
- status: hasConnectedAccount
353
- ? '$accountCount ${accountCount == 1 ? 'connected user' : 'connected users'}'
354
- : 'Not connected',
355
- isConnected: hasConnectedAccount,
356
- ),
357
- const SizedBox(height: 12),
358
- TextField(
359
- controller: baseUrlController,
360
- onChanged: (_) => setState(() {}),
361
- keyboardType: TextInputType.url,
362
- decoration: const InputDecoration(
363
- labelText: 'NeoMail Backend URL',
364
- hintText: 'https://mail.example.com',
365
- border: OutlineInputBorder(),
366
- ),
367
- ),
368
- const SizedBox(height: 8),
369
- Text(
370
- 'Use the public base URL of the NeoMail server. Local self-hosted URLs are also supported when NeoAgent can reach them.',
371
- style: TextStyle(color: _textSecondary, fontSize: 12),
372
- ),
373
- if (formError.isNotEmpty) ...<Widget>[
374
- const SizedBox(height: 12),
375
- Container(
376
- padding: const EdgeInsets.all(8),
377
- decoration: BoxDecoration(
378
- color: _danger.withValues(alpha: 0.1),
379
- borderRadius: BorderRadius.circular(4),
380
- border: Border.all(
381
- color: _danger.withValues(alpha: 0.3),
382
- ),
383
- ),
384
- child: Text(
385
- formError,
386
- style: TextStyle(color: _danger, fontSize: 12),
387
- ),
388
- ),
389
- ],
390
- ],
507
+ builder: (dialogContext) => StatefulBuilder(
508
+ builder: (dialogContext, setState) => AlertDialog(
509
+ title: const Text('NeoArchive Setup'),
510
+ content: SizedBox(
511
+ width: 540,
512
+ child: Column(
513
+ mainAxisSize: MainAxisSize.min,
514
+ crossAxisAlignment: CrossAxisAlignment.start,
515
+ children: <Widget>[
516
+ Text(
517
+ 'Add the NeoArchive backend URL once. NeoAgent will open NeoArchive OAuth so the user can sign in and approve archive access without API keys.',
518
+ style: TextStyle(color: _textSecondary),
391
519
  ),
392
- ),
393
- actions: <Widget>[
394
- if (savedBaseUrl.trim().isNotEmpty)
395
- TextButton(
396
- onPressed: busy
397
- ? null
398
- : () async {
399
- final shouldClear =
400
- await showDialog<bool>(
401
- context: dialogContext,
402
- builder: (context) {
403
- return AlertDialog(
404
- title: const Text('Disconnect NeoMail?'),
405
- content: const Text(
406
- 'This removes the NeoMail backend URL and all connected NeoMail accounts for this agent.',
407
- ),
408
- actions: [
409
- TextButton(
410
- onPressed: () =>
411
- Navigator.of(context).pop(false),
412
- child: const Text('Cancel'),
413
- ),
414
- FilledButton(
415
- onPressed: () =>
416
- Navigator.of(context).pop(true),
417
- child: const Text('Disconnect'),
418
- ),
419
- ],
420
- );
421
- },
422
- ) ??
423
- false;
424
- if (!shouldClear) {
425
- return;
426
- }
427
- setState(() {
428
- formError = '';
429
- busy = true;
430
- });
431
- try {
432
- await controller.clearOfficialIntegrationConfig(
433
- 'neomail',
434
- );
435
- if (dialogContext.mounted) {
436
- Navigator.of(dialogContext).pop();
437
- }
438
- } catch (_) {
439
- setState(() {
440
- formError =
441
- controller.errorMessage ??
442
- 'Could not disconnect NeoMail.';
443
- busy = false;
444
- });
445
- }
446
- },
447
- child: const Text('Disconnect'),
448
- ),
449
- TextButton(
450
- onPressed: busy
451
- ? null
452
- : () => Navigator.of(dialogContext).pop(),
453
- child: const Text('Close'),
520
+ const SizedBox(height: 16),
521
+ const _IntegrationSetupStatusItem(
522
+ label: 'Connection Method',
523
+ status: 'OAuth companion flow',
524
+ isConnected: true,
454
525
  ),
455
- if (!hasConnectedAccount)
456
- TextButton(
457
- onPressed: busy
458
- ? null
459
- : () => saveConfig(
460
- setState,
461
- dialogContext: dialogContext,
462
- connectAfterSave: false,
463
- ),
464
- child: const Text('Save Only'),
465
- ),
466
- FilledButton(
467
- onPressed: busy
468
- ? null
469
- : () => saveConfig(
470
- setState,
471
- dialogContext: dialogContext,
472
- connectAfterSave: !hasConnectedAccount,
473
- ),
474
- child: Text(
475
- busy
476
- ? 'Working...'
477
- : hasConnectedAccount
478
- ? 'Update Setup'
479
- : 'Save & Connect',
526
+ const SizedBox(height: 12),
527
+ _IntegrationSetupStatusItem(
528
+ label: 'Connected NeoArchive User',
529
+ status: connected
530
+ ? '$accountCount ${accountCount == 1 ? 'connected user' : 'connected users'}'
531
+ : 'Not connected',
532
+ isConnected: connected,
533
+ ),
534
+ const SizedBox(height: 12),
535
+ TextField(
536
+ controller: baseUrlController,
537
+ keyboardType: TextInputType.url,
538
+ decoration: const InputDecoration(
539
+ labelText: 'NeoArchive Backend URL',
540
+ hintText: 'https://archive.example.com',
541
+ border: OutlineInputBorder(),
480
542
  ),
481
543
  ),
544
+ const SizedBox(height: 8),
545
+ Text(
546
+ 'Use the public base URL of the NeoArchive server. Local self-hosted URLs are supported when NeoAgent can reach them.',
547
+ style: TextStyle(color: _textSecondary, fontSize: 12),
548
+ ),
549
+ if (errorText.isNotEmpty) ...<Widget>[
550
+ const SizedBox(height: 12),
551
+ Text(errorText, style: TextStyle(color: _danger, fontSize: 12)),
552
+ ],
482
553
  ],
483
- );
484
- },
485
- );
486
- },
554
+ ),
555
+ ),
556
+ actions: <Widget>[
557
+ if (savedBaseUrl.isNotEmpty)
558
+ TextButton(
559
+ onPressed: busy
560
+ ? null
561
+ : () async {
562
+ final confirm =
563
+ await showDialog<bool>(
564
+ context: dialogContext,
565
+ builder: (context) => AlertDialog(
566
+ title: const Text('Disconnect NeoArchive?'),
567
+ content: const Text(
568
+ 'This removes the NeoArchive backend URL and all connected NeoArchive accounts for this agent.',
569
+ ),
570
+ actions: <Widget>[
571
+ TextButton(
572
+ onPressed: () =>
573
+ Navigator.of(context).pop(false),
574
+ child: const Text('Cancel'),
575
+ ),
576
+ FilledButton(
577
+ onPressed: () =>
578
+ Navigator.of(context).pop(true),
579
+ child: const Text('Disconnect'),
580
+ ),
581
+ ],
582
+ ),
583
+ ) ??
584
+ false;
585
+ if (!confirm) return;
586
+ setState(() {
587
+ busy = true;
588
+ errorText = '';
589
+ });
590
+ try {
591
+ await controller.clearOfficialIntegrationConfig(
592
+ 'neoarchive',
593
+ );
594
+ if (dialogContext.mounted) {
595
+ Navigator.of(dialogContext).pop();
596
+ }
597
+ } catch (_) {
598
+ setState(() {
599
+ errorText =
600
+ controller.errorMessage ??
601
+ 'Could not disconnect NeoArchive.';
602
+ busy = false;
603
+ });
604
+ }
605
+ },
606
+ child: const Text('Disconnect'),
607
+ ),
608
+ TextButton(
609
+ onPressed: busy ? null : () => Navigator.of(dialogContext).pop(),
610
+ child: const Text('Close'),
611
+ ),
612
+ if (!connected)
613
+ TextButton(
614
+ onPressed: busy
615
+ ? null
616
+ : () => save(setState, dialogContext, connect: false),
617
+ child: const Text('Save Only'),
618
+ ),
619
+ FilledButton(
620
+ onPressed: busy
621
+ ? null
622
+ : () => save(setState, dialogContext, connect: !connected),
623
+ child: Text(
624
+ busy
625
+ ? 'Working...'
626
+ : connected
627
+ ? 'Update Setup'
628
+ : 'Save & Connect',
629
+ ),
630
+ ),
631
+ ],
632
+ ),
633
+ ),
487
634
  );
488
-
489
635
  baseUrlController.dispose();
490
636
  }
491
637
 
@@ -1394,12 +1540,16 @@ class _OfficialIntegrationIcon extends StatelessWidget {
1394
1540
  );
1395
1541
  }
1396
1542
  final color = switch (item.icon) {
1543
+ 'neoarchive' => const Color(0xFFE3B655),
1544
+ 'neorecall' => const Color(0xFFD98AA6),
1397
1545
  'google' => const Color(0xFF4285F4),
1398
1546
  'home_assistant' => const Color(0xFF41BDF5),
1399
1547
  'trello' => const Color(0xFF0C66E4),
1400
1548
  _ => _accent,
1401
1549
  };
1402
1550
  final label = switch (item.icon) {
1551
+ 'neoarchive' => 'A',
1552
+ 'neorecall' => 'R',
1403
1553
  'google' => 'G',
1404
1554
  'home_assistant' => 'H',
1405
1555
  'trello' => 'T',
@@ -1439,8 +1589,10 @@ int _compareOfficialIntegrationItems(
1439
1589
 
1440
1590
  int _officialIntegrationRank(OfficialIntegrationItem item) {
1441
1591
  return switch (item.id) {
1592
+ 'neoarchive' => 1,
1442
1593
  'neomail' => 0,
1443
- 'google_workspace' => 1,
1594
+ 'neorecall' => 2,
1595
+ 'google_workspace' => 3,
1444
1596
  _ => 10,
1445
1597
  };
1446
1598
  }