neoagent 2.3.1-beta.86 → 2.3.1-beta.87

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 (38) hide show
  1. package/docs/capabilities.md +2 -0
  2. package/flutter_app/android/app/src/main/AndroidManifest.xml +14 -0
  3. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +84 -0
  4. package/flutter_app/lib/main_chat.dart +156 -2
  5. package/flutter_app/lib/main_controller.dart +137 -10
  6. package/flutter_app/lib/main_models.dart +69 -0
  7. package/flutter_app/lib/main_operations.dart +248 -0
  8. package/flutter_app/lib/main_runtime.dart +11 -2
  9. package/flutter_app/lib/main_settings.dart +173 -176
  10. package/flutter_app/lib/main_shared.dart +78 -0
  11. package/flutter_app/lib/src/app_launch_bridge.dart +39 -10
  12. package/flutter_app/lib/src/backend_client.dart +28 -0
  13. package/package.json +1 -1
  14. package/server/http/routes.js +1 -0
  15. package/server/public/.last_build_id +1 -1
  16. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  17. package/server/public/flutter_bootstrap.js +1 -1
  18. package/server/public/main.dart.js +69936 -69277
  19. package/server/routes/memory.js +90 -0
  20. package/server/routes/social_video.js +62 -0
  21. package/server/services/ai/systemPrompt.js +1 -0
  22. package/server/services/ai/toolResult.js +20 -0
  23. package/server/services/ai/tools.js +29 -0
  24. package/server/services/manager.js +15 -0
  25. package/server/services/memory/llm_transfer.js +217 -0
  26. package/server/services/social_video/adapters/base.js +26 -0
  27. package/server/services/social_video/adapters/index.js +27 -0
  28. package/server/services/social_video/adapters/instagram.js +17 -0
  29. package/server/services/social_video/adapters/tiktok.js +17 -0
  30. package/server/services/social_video/adapters/x.js +17 -0
  31. package/server/services/social_video/adapters/youtube.js +17 -0
  32. package/server/services/social_video/captions.js +187 -0
  33. package/server/services/social_video/frame.js +42 -0
  34. package/server/services/social_video/index.js +7 -0
  35. package/server/services/social_video/metadata.js +63 -0
  36. package/server/services/social_video/result.js +63 -0
  37. package/server/services/social_video/service.js +576 -0
  38. package/server/services/social_video/url.js +83 -0
@@ -33,6 +33,7 @@ Recording sessions support:
33
33
  - Retry transcription and delete transcript segment actions.
34
34
  - Full session deletion with storage cleanup.
35
35
  - Agent tools for listing, opening, and searching transcripts: `recordings_list`, `recordings_get`, and `recordings_search`.
36
+ - A keyless social video extractor for public YouTube, TikTok, Instagram, and X URLs through `social_video_extract`, with metadata-first fetch, captions-first transcript strategy, STT fallback, and representative frame extraction.
36
37
 
37
38
  Transcription uses Deepgram when `DEEPGRAM_API_KEY` is configured. The default speech model is `nova-3`, and the default language mode is `multi`. When `auto_recording_insights` is enabled in AI settings, NeoAgent can generate structured recording insights such as a summary, action items, and events.
38
39
 
@@ -102,6 +103,7 @@ NeoAgent's agent tool surface includes more than basic chat:
102
103
  | Output | Generate markdown tables and Mermaid graphs |
103
104
  | Images | Generate images with Grok and analyze local image files with a vision-capable model |
104
105
  | Recordings | List, inspect, and search recording transcripts |
106
+ | Social video | Extract title, description, transcript, and a representative frame from public social video URLs |
105
107
  | Health | Read synced mobile health metrics |
106
108
 
107
109
  Generated binary or text artifacts can be promoted into user-scoped artifact storage under `~/.neoagent/data/artifacts` and served through authenticated `/api/artifacts/:id/content` URLs.
@@ -56,6 +56,20 @@
56
56
  <action android:name="android.intent.action.MAIN"/>
57
57
  <category android:name="android.intent.category.LAUNCHER"/>
58
58
  </intent-filter>
59
+ <intent-filter>
60
+ <action android:name="android.intent.action.SEND" />
61
+ <category android:name="android.intent.category.DEFAULT" />
62
+ <data android:mimeType="text/*" />
63
+ <data android:mimeType="video/*" />
64
+ <data android:mimeType="image/*" />
65
+ <data android:mimeType="audio/*" />
66
+ <data android:mimeType="application/*" />
67
+ </intent-filter>
68
+ <intent-filter>
69
+ <action android:name="android.intent.action.SEND_MULTIPLE" />
70
+ <category android:name="android.intent.category.DEFAULT" />
71
+ <data android:mimeType="*/*" />
72
+ </intent-filter>
59
73
  </activity>
60
74
  <provider
61
75
  android:name="androidx.core.content.FileProvider"
@@ -8,6 +8,7 @@ import android.net.Uri
8
8
  import android.os.BatteryManager
9
9
  import android.os.Build
10
10
  import android.provider.Settings
11
+ import android.provider.OpenableColumns
11
12
  import android.view.KeyEvent
12
13
  import androidx.activity.result.ActivityResultLauncher
13
14
  import androidx.activity.result.contract.ActivityResultContracts
@@ -55,6 +56,7 @@ class MainActivity : FlutterFragmentActivity() {
55
56
  private var widgetEventSink: EventChannel.EventSink? = null
56
57
  private var appLaunchEventSink: EventChannel.EventSink? = null
57
58
  private var pendingAppLaunchAction: String? = null
59
+ private var pendingSharePayload: Map<String, Any?>? = null
58
60
 
59
61
  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
60
62
  super.configureFlutterEngine(flutterEngine)
@@ -590,6 +592,7 @@ class MainActivity : FlutterFragmentActivity() {
590
592
 
591
593
  captureWidgetIntent(intent)
592
594
  captureAppLaunchIntent(intent)
595
+ captureShareIntent(intent)
593
596
  }
594
597
 
595
598
  override fun onNewIntent(intent: Intent) {
@@ -597,6 +600,7 @@ class MainActivity : FlutterFragmentActivity() {
597
600
  setIntent(intent)
598
601
  captureWidgetIntent(intent)
599
602
  captureAppLaunchIntent(intent)
603
+ captureShareIntent(intent)
600
604
  }
601
605
 
602
606
  override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
@@ -723,11 +727,91 @@ class MainActivity : FlutterFragmentActivity() {
723
727
 
724
728
  private fun emitPendingAppLaunchIntent() {
725
729
  val sink = appLaunchEventSink ?: return
730
+ val sharePayload = pendingSharePayload
731
+ if (sharePayload != null) {
732
+ pendingSharePayload = null
733
+ sink.success(sharePayload)
734
+ return
735
+ }
726
736
  val action = pendingAppLaunchAction ?: return
727
737
  pendingAppLaunchAction = null
728
738
  sink.success(mapOf("action" to action))
729
739
  }
730
740
 
741
+ private fun captureShareIntent(intent: Intent?) {
742
+ if (intent == null) {
743
+ return
744
+ }
745
+ if (intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_SEND_MULTIPLE) {
746
+ return
747
+ }
748
+
749
+ val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim().orEmpty()
750
+ val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.trim().orEmpty()
751
+ val fileUris = mutableListOf<Uri>()
752
+
753
+ @Suppress("DEPRECATION")
754
+ val single = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
755
+ if (single != null) {
756
+ fileUris.add(single)
757
+ }
758
+ @Suppress("DEPRECATION")
759
+ val multiple = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
760
+ if (multiple != null) {
761
+ fileUris.addAll(multiple)
762
+ }
763
+ val clip = intent.clipData
764
+ if (clip != null) {
765
+ for (index in 0 until clip.itemCount) {
766
+ val uri = clip.getItemAt(index)?.uri ?: continue
767
+ fileUris.add(uri)
768
+ }
769
+ }
770
+
771
+ val uniqueUris = linkedSetOf<String>()
772
+ val files = mutableListOf<Map<String, Any?>>()
773
+ for (uri in fileUris) {
774
+ val key = uri.toString()
775
+ if (!uniqueUris.add(key)) continue
776
+ files.add(buildSharedFileDescriptor(uri))
777
+ }
778
+
779
+ pendingSharePayload = mapOf(
780
+ "action" to "share_to_chat",
781
+ "text" to sharedText,
782
+ "subject" to subject,
783
+ "files" to files,
784
+ )
785
+ emitPendingAppLaunchIntent()
786
+ }
787
+
788
+ private fun buildSharedFileDescriptor(uri: Uri): Map<String, Any?> {
789
+ var name: String? = null
790
+ var sizeBytes: Long? = null
791
+ val type = contentResolver.getType(uri)?.trim().orEmpty()
792
+ contentResolver.query(uri, null, null, null, null)?.use { cursor ->
793
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
794
+ val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
795
+ if (cursor.moveToFirst()) {
796
+ if (nameIndex >= 0) {
797
+ name = cursor.getString(nameIndex)?.trim()
798
+ }
799
+ if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
800
+ sizeBytes = cursor.getLong(sizeIndex)
801
+ }
802
+ }
803
+ }
804
+ val fallbackName = uri.lastPathSegment?.substringAfterLast('/')?.trim().orEmpty()
805
+ val resolvedName = (name ?: fallbackName).ifBlank { "Attachment" }
806
+ return mapOf(
807
+ "uri" to uri.toString(),
808
+ "name" to resolvedName,
809
+ "mimeType" to if (type.isBlank()) "application/octet-stream" else type,
810
+ "sizeBytes" to sizeBytes,
811
+ "source" to "android_share_intent",
812
+ )
813
+ }
814
+
731
815
  private fun buildVolumeState(audioManager: AudioManager): Map<String, Any> {
732
816
  val minVolume =
733
817
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -12,6 +12,8 @@ class ChatPanel extends StatefulWidget {
12
12
  class _ChatPanelState extends State<ChatPanel> {
13
13
  late final TextEditingController _composerController;
14
14
  final ScrollController _scrollController = ScrollController();
15
+ List<SharedChatAttachment> _pendingSharedAttachments =
16
+ const <SharedChatAttachment>[];
15
17
  int _lastMessageCount = 0;
16
18
  int _lastToolCount = 0;
17
19
  String _lastStream = '';
@@ -35,12 +37,23 @@ class _ChatPanelState extends State<ChatPanel> {
35
37
 
36
38
  void _consumeQueuedDraft() {
37
39
  final draft = widget.controller.takePendingChatDraft();
40
+ final attachments = widget.controller.takePendingSharedChatAttachments();
38
41
  if (draft == null || draft.isEmpty) {
42
+ if (attachments.isNotEmpty) {
43
+ setState(() {
44
+ _pendingSharedAttachments = attachments;
45
+ });
46
+ }
39
47
  return;
40
48
  }
41
49
  _composerController
42
50
  ..text = draft
43
51
  ..selection = TextSelection.collapsed(offset: draft.length);
52
+ if (attachments.isNotEmpty) {
53
+ setState(() {
54
+ _pendingSharedAttachments = attachments;
55
+ });
56
+ }
44
57
  }
45
58
 
46
59
  void _scrollToBottom() {
@@ -151,6 +164,29 @@ class _ChatPanelState extends State<ChatPanel> {
151
164
  ),
152
165
  child: Column(
153
166
  children: <Widget>[
167
+ if (_pendingSharedAttachments.isNotEmpty)
168
+ Padding(
169
+ padding: const EdgeInsets.only(bottom: 10),
170
+ child: _SharedAttachmentTray(
171
+ attachments: _pendingSharedAttachments,
172
+ onRemoveAt: (index) {
173
+ setState(() {
174
+ _pendingSharedAttachments = _pendingSharedAttachments
175
+ .asMap()
176
+ .entries
177
+ .where((entry) => entry.key != index)
178
+ .map((entry) => entry.value)
179
+ .toList(growable: false);
180
+ });
181
+ },
182
+ onClear: () {
183
+ setState(() {
184
+ _pendingSharedAttachments =
185
+ const <SharedChatAttachment>[];
186
+ });
187
+ },
188
+ ),
189
+ ),
154
190
  Container(
155
191
  padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
156
192
  decoration: BoxDecoration(
@@ -199,7 +235,8 @@ class _ChatPanelState extends State<ChatPanel> {
199
235
  ? null
200
236
  : () async {
201
237
  final task = _composerController.text;
202
- if (task.trim().isEmpty ||
238
+ if ((task.trim().isEmpty &&
239
+ _pendingSharedAttachments.isEmpty) ||
203
240
  _isSendingChatMessage) {
204
241
  return;
205
242
  }
@@ -207,8 +244,17 @@ class _ChatPanelState extends State<ChatPanel> {
207
244
  _isSendingChatMessage = true;
208
245
  });
209
246
  _composerController.clear();
247
+ final outgoingAttachments =
248
+ _pendingSharedAttachments;
249
+ setState(() {
250
+ _pendingSharedAttachments =
251
+ const <SharedChatAttachment>[];
252
+ });
210
253
  try {
211
- await controller.sendMessage(task);
254
+ await controller.sendMessage(
255
+ task,
256
+ sharedAttachments: outgoingAttachments,
257
+ );
212
258
  } finally {
213
259
  if (mounted) {
214
260
  setState(() {
@@ -268,6 +314,114 @@ class _ChatPanelState extends State<ChatPanel> {
268
314
  }
269
315
  }
270
316
 
317
+ class _SharedAttachmentTray extends StatelessWidget {
318
+ const _SharedAttachmentTray({
319
+ required this.attachments,
320
+ required this.onRemoveAt,
321
+ required this.onClear,
322
+ });
323
+
324
+ final List<SharedChatAttachment> attachments;
325
+ final void Function(int index) onRemoveAt;
326
+ final VoidCallback onClear;
327
+
328
+ IconData _iconForMime(String mime) {
329
+ final normalized = mime.toLowerCase();
330
+ if (normalized.startsWith('image/')) return Icons.image_outlined;
331
+ if (normalized.startsWith('video/')) return Icons.videocam_outlined;
332
+ if (normalized.startsWith('audio/')) return Icons.audiotrack_outlined;
333
+ if (normalized.contains('pdf')) return Icons.picture_as_pdf_outlined;
334
+ return Icons.attach_file_rounded;
335
+ }
336
+
337
+ @override
338
+ Widget build(BuildContext context) {
339
+ return Container(
340
+ width: double.infinity,
341
+ padding: const EdgeInsets.all(10),
342
+ decoration: BoxDecoration(
343
+ color: _bgSecondary,
344
+ borderRadius: BorderRadius.circular(12),
345
+ border: Border.all(color: _borderLight),
346
+ ),
347
+ child: Column(
348
+ crossAxisAlignment: CrossAxisAlignment.start,
349
+ children: <Widget>[
350
+ Row(
351
+ children: <Widget>[
352
+ Icon(Icons.forward_to_inbox_outlined, size: 15, color: _info),
353
+ const SizedBox(width: 6),
354
+ Expanded(
355
+ child: Text(
356
+ 'Shared from another app',
357
+ style: TextStyle(
358
+ color: _textPrimary,
359
+ fontSize: 12,
360
+ fontWeight: FontWeight.w700,
361
+ ),
362
+ ),
363
+ ),
364
+ TextButton(onPressed: onClear, child: const Text('Clear')),
365
+ ],
366
+ ),
367
+ const SizedBox(height: 6),
368
+ Wrap(
369
+ spacing: 8,
370
+ runSpacing: 8,
371
+ children: attachments
372
+ .asMap()
373
+ .entries
374
+ .map((entry) {
375
+ final item = entry.value;
376
+ return Container(
377
+ padding: const EdgeInsets.symmetric(
378
+ horizontal: 10,
379
+ vertical: 7,
380
+ ),
381
+ decoration: BoxDecoration(
382
+ color: _bgCard,
383
+ borderRadius: BorderRadius.circular(999),
384
+ border: Border.all(color: _border),
385
+ ),
386
+ child: Row(
387
+ mainAxisSize: MainAxisSize.min,
388
+ children: <Widget>[
389
+ Icon(
390
+ _iconForMime(item.mimeType),
391
+ size: 14,
392
+ color: _textSecondary,
393
+ ),
394
+ const SizedBox(width: 6),
395
+ ConstrainedBox(
396
+ constraints: const BoxConstraints(maxWidth: 170),
397
+ child: Text(
398
+ item.name,
399
+ maxLines: 1,
400
+ overflow: TextOverflow.ellipsis,
401
+ style: TextStyle(color: _textPrimary, fontSize: 12),
402
+ ),
403
+ ),
404
+ const SizedBox(width: 6),
405
+ GestureDetector(
406
+ onTap: () => onRemoveAt(entry.key),
407
+ child: Icon(
408
+ Icons.close_rounded,
409
+ size: 14,
410
+ color: _textMuted,
411
+ ),
412
+ ),
413
+ ],
414
+ ),
415
+ );
416
+ })
417
+ .toList(growable: false),
418
+ ),
419
+ ],
420
+ ),
421
+ );
422
+ }
423
+ }
424
+
271
425
  class _TypingIndicatorBubble extends StatefulWidget {
272
426
  const _TypingIndicatorBubble();
273
427
 
@@ -186,6 +186,8 @@ class NeoAgentController extends ChangeNotifier {
186
186
  <String, RunDetailSnapshot>{};
187
187
  String? _selectedWidgetId;
188
188
  String? _pendingChatDraft;
189
+ List<SharedChatAttachment> _pendingSharedChatAttachments =
190
+ const <SharedChatAttachment>[];
189
191
 
190
192
  ActiveRunState? activeRun;
191
193
  List<ToolEventItem> toolEvents = const <ToolEventItem>[];
@@ -997,7 +999,7 @@ class NeoAgentController extends ChangeNotifier {
997
999
  unawaited(_analytics.trackAppUpdateCheck(silent: silent));
998
1000
  if (!appUpdaterConfigured) {
999
1001
  appUpdateErrorMessage = kIsWeb
1000
- ? 'Client app update checks are unavailable in the web app.'
1002
+ ? null
1001
1003
  : 'App updates are not configured for this build.';
1002
1004
  if (!silent) {
1003
1005
  notifyListeners();
@@ -2443,6 +2445,31 @@ class NeoAgentController extends ChangeNotifier {
2443
2445
  );
2444
2446
  notifyListeners();
2445
2447
  } catch (_) {}
2448
+
2449
+ Future<String> fetchMemoryTransferPrompt() async {
2450
+ final response = await _backendClient.fetchMemoryTransferPrompt(
2451
+ backendUrl,
2452
+ agentId: _scopedAgentId,
2453
+ );
2454
+ return response['prompt']?.toString() ?? '';
2455
+ }
2456
+
2457
+ Future<MemoryTransferImportResult> importMemoryTransfer(
2458
+ String text, {
2459
+ bool applyBehaviorNotes = true,
2460
+ bool applyCoreMemory = true,
2461
+ }) async {
2462
+ final response = await _backendClient.importMemoryTransfer(
2463
+ backendUrl,
2464
+ text: text,
2465
+ applyBehaviorNotes: applyBehaviorNotes,
2466
+ applyCoreMemory: applyCoreMemory,
2467
+ agentId: _scopedAgentId,
2468
+ );
2469
+ final result = MemoryTransferImportResult.fromJson(response);
2470
+ await refreshMemory();
2471
+ return result;
2472
+ }
2446
2473
  }
2447
2474
 
2448
2475
  Future<void> refreshMessaging() async {
@@ -2550,6 +2577,31 @@ class NeoAgentController extends ChangeNotifier {
2550
2577
  notifyListeners();
2551
2578
  }
2552
2579
 
2580
+ Future<String> fetchMemoryTransferPrompt() async {
2581
+ final response = await _backendClient.fetchMemoryTransferPrompt(
2582
+ backendUrl,
2583
+ agentId: _scopedAgentId,
2584
+ );
2585
+ return response['prompt']?.toString() ?? '';
2586
+ }
2587
+
2588
+ Future<MemoryTransferImportResult> importMemoryTransfer(
2589
+ String text, {
2590
+ bool applyBehaviorNotes = true,
2591
+ bool applyCoreMemory = true,
2592
+ }) async {
2593
+ final response = await _backendClient.importMemoryTransfer(
2594
+ backendUrl,
2595
+ text: text,
2596
+ applyBehaviorNotes: applyBehaviorNotes,
2597
+ applyCoreMemory: applyCoreMemory,
2598
+ agentId: _scopedAgentId,
2599
+ );
2600
+ final result = MemoryTransferImportResult.fromJson(response);
2601
+ await refreshMemory();
2602
+ return result;
2603
+ }
2604
+
2553
2605
  Future<void> refreshTasks() async {
2554
2606
  taskItems = _decodeModelList(
2555
2607
  'tasks',
@@ -4408,10 +4460,21 @@ class NeoAgentController extends ChangeNotifier {
4408
4460
  }
4409
4461
  }
4410
4462
 
4411
- Future<void> sendMessage(String task) async {
4463
+ Future<void> sendMessage(
4464
+ String task, {
4465
+ List<SharedChatAttachment> sharedAttachments =
4466
+ const <SharedChatAttachment>[],
4467
+ }) async {
4412
4468
  final trimmed = task.trim();
4469
+ final normalizedAttachments = sharedAttachments
4470
+ .where((item) => item.isValid)
4471
+ .toList(growable: false);
4472
+ final outgoingTask = _taskWithSharedAttachments(
4473
+ trimmed,
4474
+ normalizedAttachments,
4475
+ );
4413
4476
  final canSteerLiveRun = hasLiveRun && _socket != null && socketConnected;
4414
- if (trimmed.isEmpty || (isSendingMessage && !canSteerLiveRun)) {
4477
+ if (outgoingTask.isEmpty || (isSendingMessage && !canSteerLiveRun)) {
4415
4478
  return;
4416
4479
  }
4417
4480
  unawaited(
@@ -4424,9 +4487,20 @@ class NeoAgentController extends ChangeNotifier {
4424
4487
  final optimistic = ChatEntry(
4425
4488
  id: '',
4426
4489
  role: 'user',
4427
- content: trimmed,
4490
+ content: trimmed.isNotEmpty
4491
+ ? trimmed
4492
+ : (normalizedAttachments.isNotEmpty
4493
+ ? 'Sent shared attachments from mobile app.'
4494
+ : outgoingTask),
4428
4495
  platform: 'flutter',
4429
4496
  createdAt: DateTime.now(),
4497
+ metadata: normalizedAttachments.isEmpty
4498
+ ? const <String, dynamic>{}
4499
+ : <String, dynamic>{
4500
+ 'sharedAttachments': normalizedAttachments
4501
+ .map((item) => item.toJson())
4502
+ .toList(growable: false),
4503
+ },
4430
4504
  );
4431
4505
  chatMessages = <ChatEntry>[...chatMessages, optimistic];
4432
4506
  errorMessage = null;
@@ -4434,14 +4508,14 @@ class NeoAgentController extends ChangeNotifier {
4434
4508
  isSendingMessage = true;
4435
4509
  toolEvents = const <ToolEventItem>[];
4436
4510
  streamingAssistant = '';
4437
- activeRun = ActiveRunState.pending(trimmed);
4511
+ activeRun = ActiveRunState.pending(outgoingTask);
4438
4512
  }
4439
4513
  notifyListeners();
4440
4514
 
4441
4515
  try {
4442
4516
  if (_socket != null && socketConnected) {
4443
4517
  _socket!.emit('agent:run', <String, dynamic>{
4444
- 'task': trimmed,
4518
+ 'task': outgoingTask,
4445
4519
  'agentId': _scopedAgentId,
4446
4520
  'options': <String, dynamic>{'agentId': _scopedAgentId},
4447
4521
  });
@@ -4450,7 +4524,7 @@ class NeoAgentController extends ChangeNotifier {
4450
4524
 
4451
4525
  final response = await _backendClient.runTask(
4452
4526
  backendUrl,
4453
- trimmed,
4527
+ outgoingTask,
4454
4528
  agentId: _scopedAgentId,
4455
4529
  );
4456
4530
  final content = response['content']?.toString().trim();
@@ -4644,9 +4718,7 @@ class NeoAgentController extends ChangeNotifier {
4644
4718
  }
4645
4719
  }
4646
4720
 
4647
- Future<bool> updateAccountDisplayName({
4648
- required String displayName,
4649
- }) async {
4721
+ Future<bool> updateAccountDisplayName({required String displayName}) async {
4650
4722
  isSavingAccountSettings = true;
4651
4723
  errorMessage = null;
4652
4724
  notifyListeners();
@@ -5594,6 +5666,7 @@ class NeoAgentController extends ChangeNotifier {
5594
5666
  return;
5595
5667
  }
5596
5668
  _pendingChatDraft = normalized;
5669
+ _pendingSharedChatAttachments = const <SharedChatAttachment>[];
5597
5670
  if (!_isMobilePlatform) {
5598
5671
  setSelectedSection(AppSection.chat);
5599
5672
  } else {
@@ -5601,6 +5674,27 @@ class NeoAgentController extends ChangeNotifier {
5601
5674
  }
5602
5675
  }
5603
5676
 
5677
+ void queueSharedChatPayload({
5678
+ String? text,
5679
+ String? subject,
5680
+ List<Map<String, dynamic>> files = const <Map<String, dynamic>>[],
5681
+ }) {
5682
+ final attachments = files
5683
+ .map(SharedChatAttachment.fromJson)
5684
+ .where((item) => item.isValid)
5685
+ .toList(growable: false);
5686
+ final textPart = (text ?? '').toString().trim();
5687
+ final subjectPart = (subject ?? '').toString().trim();
5688
+ final combined = <String>[
5689
+ subjectPart,
5690
+ textPart,
5691
+ ].where((part) => part.isNotEmpty).join('\n').trim();
5692
+
5693
+ _pendingChatDraft = combined;
5694
+ _pendingSharedChatAttachments = attachments;
5695
+ setSelectedSection(AppSection.chat);
5696
+ }
5697
+
5604
5698
  bool get _isMobilePlatform =>
5605
5699
  !kIsWeb &&
5606
5700
  (defaultTargetPlatform == TargetPlatform.android ||
@@ -5612,6 +5706,39 @@ class NeoAgentController extends ChangeNotifier {
5612
5706
  return draft;
5613
5707
  }
5614
5708
 
5709
+ List<SharedChatAttachment> takePendingSharedChatAttachments() {
5710
+ final pending = _pendingSharedChatAttachments;
5711
+ _pendingSharedChatAttachments = const <SharedChatAttachment>[];
5712
+ return pending;
5713
+ }
5714
+
5715
+ String _taskWithSharedAttachments(
5716
+ String task,
5717
+ List<SharedChatAttachment> attachments,
5718
+ ) {
5719
+ final base = task.trim();
5720
+ if (attachments.isEmpty) {
5721
+ return base;
5722
+ }
5723
+ final lines = attachments
5724
+ .map((item) {
5725
+ final type = item.mimeType.trim().isEmpty
5726
+ ? 'unknown'
5727
+ : item.mimeType.trim();
5728
+ return '- ${item.name} ($type) [local uri: ${item.uri}]';
5729
+ })
5730
+ .join('\n');
5731
+ final attachmentBlock = [
5732
+ 'Shared attachments from mobile app:',
5733
+ lines,
5734
+ 'Use these for context. If the local URI is not directly accessible from the server, ask me to upload the file.',
5735
+ ].join('\n');
5736
+ if (base.isEmpty) {
5737
+ return attachmentBlock;
5738
+ }
5739
+ return '$base\n\n$attachmentBlock';
5740
+ }
5741
+
5615
5742
  void selectWidget(String? widgetId) {
5616
5743
  if (widgetId != null && !widgets.any((widget) => widget.id == widgetId)) {
5617
5744
  return;
@@ -1321,6 +1321,32 @@ class VoiceAssistantTurnResult {
1321
1321
  final String? ttsError;
1322
1322
  }
1323
1323
 
1324
+ class MemoryTransferImportResult {
1325
+ const MemoryTransferImportResult({
1326
+ required this.importedCount,
1327
+ required this.skippedCount,
1328
+ required this.coreUpdatedCount,
1329
+ required this.behaviorNotesUpdated,
1330
+ required this.warnings,
1331
+ });
1332
+
1333
+ factory MemoryTransferImportResult.fromJson(Map<dynamic, dynamic> json) {
1334
+ return MemoryTransferImportResult(
1335
+ importedCount: _asInt(json['importedCount']),
1336
+ skippedCount: _asInt(json['skippedCount']),
1337
+ coreUpdatedCount: _asInt(json['coreUpdatedCount']),
1338
+ behaviorNotesUpdated: json['behaviorNotesUpdated'] == true,
1339
+ warnings: _jsonStringList(json['warnings']),
1340
+ );
1341
+ }
1342
+
1343
+ final int importedCount;
1344
+ final int skippedCount;
1345
+ final int coreUpdatedCount;
1346
+ final bool behaviorNotesUpdated;
1347
+ final List<String> warnings;
1348
+ }
1349
+
1324
1350
  class LiveVoiceBufferedChunk {
1325
1351
  LiveVoiceBufferedChunk({
1326
1352
  required this.sequence,
@@ -1864,6 +1890,49 @@ class ChatEntry {
1864
1890
  }
1865
1891
  }
1866
1892
 
1893
+ class SharedChatAttachment {
1894
+ const SharedChatAttachment({
1895
+ required this.uri,
1896
+ required this.name,
1897
+ required this.mimeType,
1898
+ this.sizeBytes,
1899
+ this.source = 'share_intent',
1900
+ });
1901
+
1902
+ factory SharedChatAttachment.fromJson(Map<dynamic, dynamic> json) {
1903
+ return SharedChatAttachment(
1904
+ uri: json['uri']?.toString() ?? '',
1905
+ name: json['name']?.toString() ?? 'Attachment',
1906
+ mimeType:
1907
+ json['mimeType']?.toString().ifEmpty('application/octet-stream') ??
1908
+ 'application/octet-stream',
1909
+ sizeBytes: json['sizeBytes'] is num
1910
+ ? (json['sizeBytes'] as num).toInt()
1911
+ : null,
1912
+ source:
1913
+ json['source']?.toString().ifEmpty('share_intent') ?? 'share_intent',
1914
+ );
1915
+ }
1916
+
1917
+ final String uri;
1918
+ final String name;
1919
+ final String mimeType;
1920
+ final int? sizeBytes;
1921
+ final String source;
1922
+
1923
+ Map<String, dynamic> toJson() {
1924
+ return <String, dynamic>{
1925
+ 'uri': uri,
1926
+ 'name': name,
1927
+ 'mimeType': mimeType,
1928
+ if (sizeBytes != null) 'sizeBytes': sizeBytes,
1929
+ 'source': source,
1930
+ };
1931
+ }
1932
+
1933
+ bool get isValid => uri.trim().isNotEmpty;
1934
+ }
1935
+
1867
1936
  class AgentProfile {
1868
1937
  const AgentProfile({
1869
1938
  required this.id,