neoagent 2.4.1-beta.9 → 2.4.1

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 (234) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +36 -4
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -3
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +13 -0
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +2 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +197 -42
  52. package/flutter_app/lib/main_devices.dart +436 -4
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +673 -871
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +46 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
  70. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  71. package/flutter_app/lib/src/stream_renderer.dart +42 -3
  72. package/flutter_app/lib/src/theme/palette.dart +76 -34
  73. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  74. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  75. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  81. package/flutter_app/pubspec.lock +2 -2
  82. package/flutter_app/pubspec.yaml +7 -1
  83. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  84. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  85. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  94. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  95. package/flutter_app/web/favicon.png +0 -0
  96. package/flutter_app/web/favicon.svg +40 -9
  97. package/flutter_app/web/favicon_light.svg +43 -0
  98. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  99. package/flutter_app/web/icons/Icon-192.png +0 -0
  100. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-512.png +0 -0
  102. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  106. package/flutter_app/windows/runner/main.cpp +7 -1
  107. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  108. package/lib/manager.js +445 -83
  109. package/package.json +17 -3
  110. package/runtime/paths.js +3 -1
  111. package/server/admin/access.js +198 -0
  112. package/server/admin/admin.css +268 -0
  113. package/server/admin/admin.js +348 -0
  114. package/server/admin/analytics.js +128 -0
  115. package/server/admin/index.html +1015 -0
  116. package/server/admin/login.html +290 -0
  117. package/server/admin/logo.svg +43 -0
  118. package/server/admin/sql.js +134 -0
  119. package/server/admin/users.js +147 -0
  120. package/server/config/origins.js +3 -1
  121. package/server/db/database.js +92 -0
  122. package/server/db/ftsQuery.js +27 -0
  123. package/server/http/routes.js +1 -0
  124. package/server/http/static.js +23 -6
  125. package/server/index.js +1 -1
  126. package/server/middleware/adminAuth.js +48 -0
  127. package/server/middleware/auth.js +1 -40
  128. package/server/public/.last_build_id +1 -1
  129. package/server/public/app_icon.png +0 -0
  130. package/server/public/assets/AssetManifest.bin +1 -1
  131. package/server/public/assets/AssetManifest.bin.json +1 -1
  132. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  133. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  134. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  138. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  139. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  140. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  141. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  142. package/server/public/assets/web/icons/Icon-192.png +0 -0
  143. package/server/public/favicon.png +0 -0
  144. package/server/public/favicon.svg +40 -9
  145. package/server/public/favicon_light.svg +43 -0
  146. package/server/public/flutter_bootstrap.js +2 -2
  147. package/server/public/icons/Icon-192-light.png +0 -0
  148. package/server/public/icons/Icon-192.png +0 -0
  149. package/server/public/icons/Icon-512-light.png +0 -0
  150. package/server/public/icons/Icon-512.png +0 -0
  151. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  152. package/server/public/icons/Icon-maskable-192.png +0 -0
  153. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-512.png +0 -0
  155. package/server/public/main.dart.js +88537 -85936
  156. package/server/routes/admin.js +632 -0
  157. package/server/routes/agent_profiles.js +3 -0
  158. package/server/routes/agents.js +1 -1
  159. package/server/routes/auth.js +3 -1
  160. package/server/routes/mcp.js +29 -13
  161. package/server/routes/memory.js +23 -1
  162. package/server/routes/messaging.js +2 -0
  163. package/server/routes/screenHistory.js +14 -8
  164. package/server/routes/settings.js +10 -2
  165. package/server/routes/stream.js +1 -1
  166. package/server/routes/triggers.js +4 -4
  167. package/server/routes/voice_assistant.js +36 -1
  168. package/server/routes/workspace.js +86 -0
  169. package/server/services/account/admin_two_factor.js +132 -0
  170. package/server/services/account/password_policy.js +6 -1
  171. package/server/services/ai/completion.js +44 -0
  172. package/server/services/ai/engine.js +321 -500
  173. package/server/services/ai/imageAnalysis.js +9 -5
  174. package/server/services/ai/logFormat.js +46 -0
  175. package/server/services/ai/loopPolicy.js +11 -0
  176. package/server/services/ai/messagingFallback.js +228 -0
  177. package/server/services/ai/models.js +194 -55
  178. package/server/services/ai/providerRetry.js +169 -0
  179. package/server/services/ai/providers/anthropic.js +15 -4
  180. package/server/services/ai/providers/google.js +38 -11
  181. package/server/services/ai/providers/grok.js +19 -68
  182. package/server/services/ai/providers/grokOauth.js +141 -0
  183. package/server/services/ai/providers/nvidia.js +154 -0
  184. package/server/services/ai/providers/ollama.js +76 -39
  185. package/server/services/ai/providers/openai.js +20 -31
  186. package/server/services/ai/providers/openaiCodex.js +6 -2
  187. package/server/services/ai/providers/openaiCompatible.js +70 -0
  188. package/server/services/ai/providers/openrouter.js +162 -0
  189. package/server/services/ai/settings.js +30 -0
  190. package/server/services/ai/systemPrompt.js +51 -29
  191. package/server/services/ai/taskAnalysis.js +60 -1
  192. package/server/services/ai/toolEvidence.js +207 -0
  193. package/server/services/ai/toolSelector.js +8 -1
  194. package/server/services/ai/tools.js +78 -13
  195. package/server/services/browser/extension/registry.js +94 -9
  196. package/server/services/desktop/registry.js +104 -1
  197. package/server/services/desktop/screenRecorder.js +208 -98
  198. package/server/services/desktop/screen_recorder_support.js +46 -0
  199. package/server/services/integrations/google/provider.js +13 -0
  200. package/server/services/integrations/home_assistant/constants.js +88 -0
  201. package/server/services/integrations/home_assistant/network.js +207 -0
  202. package/server/services/integrations/home_assistant/provider.js +249 -0
  203. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  204. package/server/services/integrations/home_assistant/tools.js +101 -0
  205. package/server/services/integrations/manager.js +5 -0
  206. package/server/services/integrations/registry.js +2 -0
  207. package/server/services/integrations/trello/provider.js +8 -2
  208. package/server/services/manager.js +51 -53
  209. package/server/services/mcp/client.js +208 -268
  210. package/server/services/mcp/client_support.js +172 -0
  211. package/server/services/mcp/recovery.js +116 -0
  212. package/server/services/mcp/tool_operations.js +123 -0
  213. package/server/services/memory/ingestion.js +185 -370
  214. package/server/services/memory/ingestion_coverage.js +129 -0
  215. package/server/services/memory/ingestion_documents.js +123 -0
  216. package/server/services/memory/ingestion_support.js +171 -0
  217. package/server/services/memory/intelligence.js +181 -0
  218. package/server/services/memory/manager.js +476 -32
  219. package/server/services/messaging/automation.js +71 -134
  220. package/server/services/messaging/formatting_guides.js +26 -1
  221. package/server/services/messaging/inbound_queue.js +111 -0
  222. package/server/services/messaging/manager.js +25 -8
  223. package/server/services/messaging/typing_keepalive.js +110 -0
  224. package/server/services/streaming/android-stream.js +293 -40
  225. package/server/services/tasks/integration_runtime.js +4 -1
  226. package/server/services/tasks/runtime.js +415 -56
  227. package/server/services/tasks/task_repository.js +48 -6
  228. package/server/services/websocket.js +8 -2
  229. package/server/services/widgets/service.js +17 -1
  230. package/server/services/workspace/manager.js +116 -0
  231. package/server/utils/logger.js +44 -6
  232. package/server/utils/security.js +3 -0
  233. package/server/utils/version.js +29 -19
  234. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -470,7 +470,7 @@ class _LogsPanelState extends State<LogsPanel> {
470
470
  style: TextStyle(
471
471
  fontSize: 12,
472
472
  height: 1.5,
473
- fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
473
+ fontFamily: GoogleFonts.geistMono().fontFamily,
474
474
  ),
475
475
  ),
476
476
  );
@@ -1240,11 +1240,14 @@ class MemoryPanel extends StatefulWidget {
1240
1240
  State<MemoryPanel> createState() => _MemoryPanelState();
1241
1241
  }
1242
1242
 
1243
- class _MemoryPanelState extends State<MemoryPanel> {
1243
+ class _MemoryPanelState extends State<MemoryPanel>
1244
+ with SingleTickerProviderStateMixin {
1244
1245
  late final TextEditingController _searchController;
1245
1246
  late final TextEditingController _llmPromptController;
1246
1247
  late final TextEditingController _llmImportController;
1248
+ late final TabController _tabController;
1247
1249
  final Set<String> _selectedMemoryIds = <String>{};
1250
+ String? _entityFilter;
1248
1251
  bool _bulkActionInFlight = false;
1249
1252
  bool _llmPromptLoading = false;
1250
1253
  bool _llmImporting = false;
@@ -1257,6 +1260,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
1257
1260
  _searchController = TextEditingController();
1258
1261
  _llmPromptController = TextEditingController();
1259
1262
  _llmImportController = TextEditingController();
1263
+ _tabController = TabController(length: 3, vsync: this);
1260
1264
  }
1261
1265
 
1262
1266
  @override
@@ -1264,62 +1268,41 @@ class _MemoryPanelState extends State<MemoryPanel> {
1264
1268
  _searchController.dispose();
1265
1269
  _llmPromptController.dispose();
1266
1270
  _llmImportController.dispose();
1271
+ _tabController.dispose();
1267
1272
  super.dispose();
1268
1273
  }
1269
1274
 
1270
1275
  Future<void> _loadLlmPrompt(NeoAgentController controller) async {
1271
- if (_llmPromptLoading) {
1272
- return;
1273
- }
1274
- setState(() {
1275
- _llmPromptLoading = true;
1276
- });
1276
+ if (_llmPromptLoading) return;
1277
+ setState(() => _llmPromptLoading = true);
1277
1278
  try {
1278
1279
  final prompt = await controller.fetchMemoryTransferPrompt();
1279
- if (!mounted) {
1280
- return;
1281
- }
1282
- setState(() {
1283
- _llmPromptController.text = prompt;
1284
- });
1280
+ if (!mounted) return;
1281
+ setState(() => _llmPromptController.text = prompt);
1285
1282
  } catch (error) {
1286
- if (!mounted) {
1287
- return;
1288
- }
1283
+ if (!mounted) return;
1289
1284
  ScaffoldMessenger.of(context).showSnackBar(
1290
1285
  SnackBar(content: Text('Failed to generate prompt: $error')),
1291
1286
  );
1292
1287
  } finally {
1293
- if (mounted) {
1294
- setState(() {
1295
- _llmPromptLoading = false;
1296
- });
1297
- }
1288
+ if (mounted) setState(() => _llmPromptLoading = false);
1298
1289
  }
1299
1290
  }
1300
1291
 
1301
1292
  Future<void> _copyLlmPrompt() async {
1302
1293
  final prompt = _llmPromptController.text.trim();
1303
- if (prompt.isEmpty) {
1304
- return;
1305
- }
1294
+ if (prompt.isEmpty) return;
1306
1295
  await Clipboard.setData(ClipboardData(text: prompt));
1307
- if (!mounted) {
1308
- return;
1309
- }
1296
+ if (!mounted) return;
1310
1297
  ScaffoldMessenger.of(
1311
1298
  context,
1312
1299
  ).showSnackBar(const SnackBar(content: Text('Prompt copied.')));
1313
1300
  }
1314
1301
 
1315
1302
  Future<void> _importLlmMemories(NeoAgentController controller) async {
1316
- if (_llmImporting) {
1317
- return;
1318
- }
1303
+ if (_llmImporting) return;
1319
1304
  final text = _llmImportController.text.trim();
1320
- if (text.isEmpty) {
1321
- return;
1322
- }
1305
+ if (text.isEmpty) return;
1323
1306
  final confirmImport = await showDialog<bool>(
1324
1307
  context: context,
1325
1308
  builder: (context) {
@@ -1328,12 +1311,11 @@ class _MemoryPanelState extends State<MemoryPanel> {
1328
1311
  if (_llmApplyCoreMemory) 'core memory',
1329
1312
  'memories',
1330
1313
  ];
1331
- final targetLabel = applyTargets.join(', ');
1332
1314
  return AlertDialog(
1333
1315
  backgroundColor: _bgCard,
1334
1316
  title: Text('Import memory transfer?'),
1335
1317
  content: Text(
1336
- 'This will import the response into $targetLabel.',
1318
+ 'This will import the response into ${applyTargets.join(', ')}.',
1337
1319
  ),
1338
1320
  actions: <Widget>[
1339
1321
  TextButton(
@@ -1348,25 +1330,18 @@ class _MemoryPanelState extends State<MemoryPanel> {
1348
1330
  );
1349
1331
  },
1350
1332
  );
1351
- if (confirmImport != true) {
1352
- return;
1353
- }
1354
- setState(() {
1355
- _llmImporting = true;
1356
- });
1333
+ if (confirmImport != true) return;
1334
+ setState(() => _llmImporting = true);
1357
1335
  try {
1358
1336
  final result = await controller.importMemoryTransfer(
1359
1337
  text,
1360
1338
  applyBehaviorNotes: _llmApplyBehaviorNotes,
1361
1339
  applyCoreMemory: _llmApplyCoreMemory,
1362
1340
  );
1363
- if (!mounted) {
1364
- return;
1365
- }
1341
+ if (!mounted) return;
1366
1342
  _llmImportController.clear();
1367
- final warningText = result.warnings.isEmpty
1368
- ? ''
1369
- : ' ${result.warnings.join(' ')}';
1343
+ final warningText =
1344
+ result.warnings.isEmpty ? '' : ' ${result.warnings.join(' ')}';
1370
1345
  ScaffoldMessenger.of(context).showSnackBar(
1371
1346
  SnackBar(
1372
1347
  content: Text(
@@ -1378,30 +1353,30 @@ class _MemoryPanelState extends State<MemoryPanel> {
1378
1353
  ),
1379
1354
  );
1380
1355
  } catch (error) {
1381
- if (!mounted) {
1382
- return;
1383
- }
1384
- ScaffoldMessenger.of(context).showSnackBar(
1385
- SnackBar(content: Text('Import failed: $error')),
1386
- );
1356
+ if (!mounted) return;
1357
+ ScaffoldMessenger.of(
1358
+ context,
1359
+ ).showSnackBar(SnackBar(content: Text('Import failed: $error')));
1387
1360
  } finally {
1388
- if (mounted) {
1389
- setState(() {
1390
- _llmImporting = false;
1391
- });
1392
- }
1361
+ if (mounted) setState(() => _llmImporting = false);
1393
1362
  }
1394
1363
  }
1395
1364
 
1396
1365
  List<MemoryItem> get _visibleMemories {
1397
1366
  final controller = widget.controller;
1398
- return controller.memoryRecallResults.isNotEmpty
1367
+ final base = controller.memoryRecallResults.isNotEmpty
1399
1368
  ? controller.memoryRecallResults
1400
1369
  : controller.memories;
1370
+ if (_entityFilter == null) return base;
1371
+ return base
1372
+ .where(
1373
+ (m) => m.entities.any((e) => e.name == _entityFilter),
1374
+ )
1375
+ .toList();
1401
1376
  }
1402
1377
 
1403
1378
  List<String> get _selectedVisibleMemoryIds {
1404
- final visibleIds = _visibleMemories.map((memory) => memory.id).toSet();
1379
+ final visibleIds = _visibleMemories.map((m) => m.id).toSet();
1405
1380
  return _selectedMemoryIds
1406
1381
  .where(visibleIds.contains)
1407
1382
  .toList(growable: false);
@@ -1418,20 +1393,14 @@ class _MemoryPanelState extends State<MemoryPanel> {
1418
1393
  }
1419
1394
 
1420
1395
  void _clearMemorySelection() {
1421
- if (_selectedMemoryIds.isEmpty) {
1422
- return;
1423
- }
1424
- setState(() {
1425
- _selectedMemoryIds.clear();
1426
- });
1396
+ if (_selectedMemoryIds.isEmpty) return;
1397
+ setState(() => _selectedMemoryIds.clear());
1427
1398
  }
1428
1399
 
1429
1400
  void _selectAllVisibleMemories(List<MemoryItem> memories) {
1430
- if (memories.isEmpty) {
1431
- return;
1432
- }
1401
+ if (memories.isEmpty) return;
1433
1402
  setState(() {
1434
- _selectedMemoryIds.addAll(memories.map((memory) => memory.id));
1403
+ _selectedMemoryIds.addAll(memories.map((m) => m.id));
1435
1404
  });
1436
1405
  }
1437
1406
 
@@ -1448,6 +1417,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
1448
1417
  void _resetMemorySearch(NeoAgentController controller) {
1449
1418
  _searchController.clear();
1450
1419
  _clearMemorySelection();
1420
+ setState(() => _entityFilter = null);
1451
1421
  controller.clearMemorySearch();
1452
1422
  }
1453
1423
 
@@ -1456,12 +1426,8 @@ class _MemoryPanelState extends State<MemoryPanel> {
1456
1426
  String id,
1457
1427
  ) async {
1458
1428
  await controller.deleteMemory(id);
1459
- if (!mounted) {
1460
- return;
1461
- }
1462
- setState(() {
1463
- _selectedMemoryIds.remove(id);
1464
- });
1429
+ if (!mounted) return;
1430
+ setState(() => _selectedMemoryIds.remove(id));
1465
1431
  }
1466
1432
 
1467
1433
  Future<void> _runBulkMemoryAction({
@@ -1471,55 +1437,50 @@ class _MemoryPanelState extends State<MemoryPanel> {
1471
1437
  required Future<void> Function(List<String> ids) onConfirm,
1472
1438
  }) async {
1473
1439
  final ids = _selectedVisibleMemoryIds;
1474
- if (ids.isEmpty || _bulkActionInFlight) {
1475
- return;
1476
- }
1440
+ if (ids.isEmpty || _bulkActionInFlight) return;
1477
1441
  await _confirmDelete(
1478
1442
  context,
1479
1443
  title: title,
1480
1444
  message: message,
1481
1445
  confirmLabel: confirmLabel,
1482
1446
  onConfirm: () async {
1483
- setState(() {
1484
- _bulkActionInFlight = true;
1485
- });
1447
+ setState(() => _bulkActionInFlight = true);
1486
1448
  try {
1487
1449
  await onConfirm(ids);
1488
- if (!mounted) {
1489
- return;
1490
- }
1491
- setState(() {
1492
- _selectedMemoryIds.removeAll(ids);
1493
- });
1450
+ if (!mounted) return;
1451
+ setState(() => _selectedMemoryIds.removeAll(ids));
1494
1452
  } finally {
1495
- if (mounted) {
1496
- setState(() {
1497
- _bulkActionInFlight = false;
1498
- });
1499
- }
1453
+ if (mounted) setState(() => _bulkActionInFlight = false);
1500
1454
  }
1501
1455
  },
1502
1456
  );
1503
1457
  }
1504
1458
 
1459
+ void _onEntityTapped(String entityName) {
1460
+ setState(() {
1461
+ _entityFilter = _entityFilter == entityName ? null : entityName;
1462
+ _tabController.animateTo(0);
1463
+ });
1464
+ }
1465
+
1505
1466
  @override
1506
1467
  Widget build(BuildContext context) {
1507
1468
  final controller = widget.controller;
1469
+ final stats = controller.memoryOverview.stats;
1508
1470
  final memoriesToShow = _visibleMemories;
1509
- final selectedMemoryIds = _selectedVisibleMemoryIds.toSet();
1510
- final selectedCount = selectedMemoryIds.length;
1511
- final allVisibleSelected =
1512
- memoriesToShow.isNotEmpty &&
1513
- memoriesToShow.every((memory) => selectedMemoryIds.contains(memory.id));
1471
+ final selectedIds = _selectedVisibleMemoryIds.toSet();
1472
+ final selectedCount = selectedIds.length;
1473
+ final allVisibleSelected = memoriesToShow.isNotEmpty &&
1474
+ memoriesToShow.every((m) => selectedIds.contains(m.id));
1514
1475
  final showingSearchResults = controller.memoryRecallResults.isNotEmpty;
1476
+ final compact = MediaQuery.sizeOf(context).width < 760;
1515
1477
 
1516
1478
  return ListView(
1517
1479
  padding: _pagePadding(context),
1518
1480
  children: <Widget>[
1519
1481
  _PageTitle(
1520
1482
  title: 'Memory',
1521
- subtitle:
1522
- 'Core memory, thread context, long-term recall, daily logs, and behavior notes.',
1483
+ subtitle: 'Long-term recall, structured facts, and knowledge graph.',
1523
1484
  trailing: Wrap(
1524
1485
  spacing: 10,
1525
1486
  runSpacing: 10,
@@ -1537,495 +1498,554 @@ class _MemoryPanelState extends State<MemoryPanel> {
1537
1498
  ],
1538
1499
  ),
1539
1500
  ),
1540
- Row(
1541
- children: <Widget>[
1542
- Expanded(
1543
- child: _OverviewCard(
1544
- title: 'Behavior Notes',
1545
- value: '${controller.memoryOverview.behaviorNotesLength} chars',
1546
- helper: 'Durable assistant style guidance',
1547
- ),
1548
- ),
1549
- const SizedBox(width: 12),
1550
- Expanded(
1551
- child: _OverviewCard(
1552
- title: 'Core Memory',
1553
- value: '${controller.memoryOverview.coreCount}',
1554
- helper: 'Pinned key/value entries',
1555
- ),
1556
- ),
1557
- const SizedBox(width: 12),
1558
- Expanded(
1559
- child: _OverviewCard(
1560
- title: 'Daily Logs',
1561
- value: '${controller.memoryOverview.dailyLogCount}',
1562
- helper: 'Recent captured log files',
1563
- ),
1564
- ),
1565
- const SizedBox(width: 12),
1566
- Expanded(
1567
- child: _OverviewCard(
1568
- title: 'API Keys',
1569
- value: '${controller.memoryOverview.apiKeyCount}',
1570
- helper: 'Masked agent-managed credentials',
1571
- ),
1572
- ),
1573
- ],
1574
- ),
1575
- const SizedBox(height: 16),
1576
- Card(
1577
- child: Padding(
1578
- padding: const EdgeInsets.all(18),
1579
- child: Column(
1580
- crossAxisAlignment: CrossAxisAlignment.start,
1581
- children: <Widget>[
1582
- const _SectionTitle('Recall Search'),
1583
- const SizedBox(height: 12),
1584
- Row(
1585
- children: <Widget>[
1586
- Expanded(
1587
- child: TextField(
1588
- controller: _searchController,
1589
- decoration: const InputDecoration(
1590
- labelText: 'Search memory',
1501
+
1502
+ // --- Stats bar ---
1503
+ _EntranceMotion(
1504
+ child: Card(
1505
+ child: Padding(
1506
+ padding: const EdgeInsets.all(18),
1507
+ child: Row(
1508
+ children: <Widget>[
1509
+ _MemoryConfidenceGauge(
1510
+ confidence: stats.averageConfidence,
1511
+ ),
1512
+ const SizedBox(width: 18),
1513
+ Expanded(
1514
+ child: Wrap(
1515
+ spacing: 10,
1516
+ runSpacing: 10,
1517
+ children: <Widget>[
1518
+ _MemoryStatChip(
1519
+ label: '${stats.active}',
1520
+ caption: 'Active',
1521
+ icon: Icons.memory_outlined,
1591
1522
  ),
1592
- onSubmitted: (_) => _runMemorySearch(controller),
1593
- ),
1594
- ),
1595
- const SizedBox(width: 10),
1596
- FilledButton(
1597
- onPressed: () => _runMemorySearch(controller),
1598
- child: Text('Search'),
1599
- ),
1600
- const SizedBox(width: 10),
1601
- OutlinedButton(
1602
- onPressed: () => _resetMemorySearch(controller),
1603
- child: Text('Reset'),
1523
+ _MemoryStatChip(
1524
+ label: '${stats.facts}',
1525
+ caption: 'Facts',
1526
+ icon: Icons.fact_check_outlined,
1527
+ ),
1528
+ _MemoryStatChip(
1529
+ label: '${stats.entities}',
1530
+ caption: 'Entities',
1531
+ icon: Icons.hub_outlined,
1532
+ ),
1533
+ _MemoryStatChip(
1534
+ label: '${stats.knowledgeViews}',
1535
+ caption: 'Reflections',
1536
+ icon: Icons.auto_stories_outlined,
1537
+ ),
1538
+ _MemoryStatChip(
1539
+ label: '${stats.ingestionDocuments}',
1540
+ caption: 'Docs',
1541
+ icon: Icons.source_outlined,
1542
+ ),
1543
+ _MemoryStatChip(
1544
+ label: stats.averageImportance.toStringAsFixed(1),
1545
+ caption: 'Avg imp.',
1546
+ icon: Icons.priority_high_outlined,
1547
+ ),
1548
+ ],
1604
1549
  ),
1605
- ],
1606
- ),
1607
- ],
1550
+ ),
1551
+ ],
1552
+ ),
1608
1553
  ),
1609
1554
  ),
1610
1555
  ),
1556
+
1611
1557
  const SizedBox(height: 16),
1612
- Card(
1613
- child: Theme(
1614
- data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
1615
- child: ExpansionTile(
1616
- initiallyExpanded: false,
1617
- tilePadding: const EdgeInsets.symmetric(
1618
- horizontal: 18,
1619
- vertical: 6,
1620
- ),
1621
- childrenPadding: const EdgeInsets.fromLTRB(18, 0, 18, 18),
1622
- leading: Icon(Icons.swap_horiz_outlined, color: _textSecondary),
1623
- title: const _SectionTitle('LLM Memory Transfer'),
1624
- subtitle: Text(
1625
- 'Export/import memories with another AI in one shot.',
1626
- style: TextStyle(color: _textSecondary),
1627
- ),
1628
- children: <Widget>[
1629
- Text(
1630
- 'Generate a prompt to use in another AI, then paste the response here to import memories.',
1631
- style: TextStyle(color: _textSecondary),
1632
- ),
1633
- const SizedBox(height: 12),
1634
- Wrap(
1635
- spacing: 10,
1636
- runSpacing: 10,
1558
+
1559
+ // --- Entity knowledge graph ---
1560
+ if (controller.memoryOverview.entities.isNotEmpty) ...<Widget>[
1561
+ _EntranceMotion(
1562
+ child: Card(
1563
+ child: Padding(
1564
+ padding: const EdgeInsets.all(18),
1565
+ child: Column(
1566
+ crossAxisAlignment: CrossAxisAlignment.start,
1637
1567
  children: <Widget>[
1638
- FilledButton.icon(
1639
- onPressed: _llmPromptLoading
1640
- ? null
1641
- : () => _loadLlmPrompt(controller),
1642
- icon: Icon(Icons.auto_awesome_outlined),
1643
- label: Text(
1644
- _llmPromptLoading ? 'Generating...' : 'Generate Prompt',
1568
+ Row(
1569
+ children: <Widget>[
1570
+ Expanded(
1571
+ child: const _SectionTitle('Knowledge Graph'),
1572
+ ),
1573
+ if (_entityFilter != null)
1574
+ TextButton.icon(
1575
+ onPressed: () =>
1576
+ setState(() => _entityFilter = null),
1577
+ icon: Icon(Icons.close, size: 16),
1578
+ label: Text('Clear filter'),
1579
+ ),
1580
+ ],
1581
+ ),
1582
+ const SizedBox(height: 4),
1583
+ Text(
1584
+ 'Tap an entity to filter memories by it.',
1585
+ style: TextStyle(
1586
+ color: _textSecondary,
1587
+ fontSize: 12,
1645
1588
  ),
1646
1589
  ),
1647
- OutlinedButton.icon(
1648
- onPressed: _llmPromptController.text.trim().isEmpty
1649
- ? null
1650
- : _copyLlmPrompt,
1651
- icon: Icon(Icons.copy_all_outlined),
1652
- label: Text('Copy Prompt'),
1590
+ const SizedBox(height: 14),
1591
+ SizedBox(
1592
+ height: compact ? 260 : 320,
1593
+ child: _EntityGraphView(
1594
+ entities: controller.memoryOverview.entities,
1595
+ knowledgeViews:
1596
+ controller.memoryOverview.knowledgeViews,
1597
+ selectedEntity: _entityFilter,
1598
+ onEntityTapped: _onEntityTapped,
1599
+ ),
1653
1600
  ),
1654
1601
  ],
1655
1602
  ),
1656
- const SizedBox(height: 12),
1657
- TextField(
1658
- controller: _llmPromptController,
1659
- minLines: 6,
1660
- maxLines: 10,
1661
- readOnly: true,
1662
- decoration: const InputDecoration(
1663
- labelText: 'Prompt to paste into another AI',
1664
- ),
1665
- ),
1666
- const SizedBox(height: 12),
1667
- SwitchListTile.adaptive(
1668
- contentPadding: EdgeInsets.zero,
1669
- value: _llmApplyBehaviorNotes,
1670
- onChanged: _llmImporting
1671
- ? null
1672
- : (value) {
1673
- setState(() {
1674
- _llmApplyBehaviorNotes = value;
1675
- });
1676
- },
1677
- title: Text('Apply behavior notes'),
1678
- subtitle: Text(
1679
- 'Overwrite assistant behavior notes from the import.',
1680
- ),
1681
- ),
1682
- SwitchListTile.adaptive(
1683
- contentPadding: EdgeInsets.zero,
1684
- value: _llmApplyCoreMemory,
1685
- onChanged: _llmImporting
1686
- ? null
1687
- : (value) {
1688
- setState(() {
1689
- _llmApplyCoreMemory = value;
1690
- });
1691
- },
1692
- title: Text('Apply core memory'),
1693
- subtitle: Text(
1694
- 'Update core memory key/value entries from the import.',
1695
- ),
1696
- ),
1697
- const SizedBox(height: 16),
1698
- Text(
1699
- 'Paste the response from the other AI below, then import.',
1700
- style: TextStyle(color: _textSecondary),
1701
- ),
1702
- const SizedBox(height: 12),
1703
- TextField(
1704
- controller: _llmImportController,
1705
- minLines: 6,
1706
- maxLines: 12,
1707
- decoration: const InputDecoration(
1708
- labelText: 'LLM memory export response',
1709
- ),
1710
- ),
1711
- const SizedBox(height: 12),
1712
- FilledButton.icon(
1713
- onPressed: _llmImporting
1714
- ? null
1715
- : () => _importLlmMemories(controller),
1716
- icon: Icon(Icons.file_download_outlined),
1717
- label: Text(_llmImporting ? 'Importing...' : 'Import'),
1718
- ),
1719
- ],
1603
+ ),
1720
1604
  ),
1721
1605
  ),
1722
- ),
1723
- const SizedBox(height: 16),
1724
- Card(
1725
- child: Padding(
1726
- padding: const EdgeInsets.all(18),
1606
+ const SizedBox(height: 16),
1607
+ ],
1608
+
1609
+ // --- Tabbed content ---
1610
+ _EntranceMotion(
1611
+ child: Card(
1727
1612
  child: Column(
1728
- crossAxisAlignment: CrossAxisAlignment.start,
1729
1613
  children: <Widget>[
1730
- Row(
1731
- children: <Widget>[
1732
- Expanded(child: _SectionTitle('Core Memory')),
1733
- TextButton.icon(
1734
- onPressed: () =>
1735
- _openCoreMemoryEditor(context, controller),
1736
- icon: Icon(Icons.add),
1737
- label: Text('Add Entry'),
1738
- ),
1739
- ],
1740
- ),
1741
- const SizedBox(height: 10),
1742
- if (controller.memoryOverview.coreEntries.isEmpty)
1743
- Text(
1744
- 'No core memory entries yet.',
1745
- style: TextStyle(color: _textSecondary),
1746
- )
1747
- else
1748
- ...controller.memoryOverview.coreEntries.entries.map((entry) {
1749
- return Container(
1750
- width: double.infinity,
1751
- margin: const EdgeInsets.only(bottom: 10),
1752
- padding: const EdgeInsets.all(12),
1753
- decoration: BoxDecoration(
1754
- color: _bgSecondary,
1755
- borderRadius: BorderRadius.circular(12),
1756
- border: Border.all(color: _border),
1757
- ),
1614
+ TabBar(
1615
+ controller: _tabController,
1616
+ labelColor: _accentHover,
1617
+ unselectedLabelColor: _textSecondary,
1618
+ indicatorColor: _accent,
1619
+ indicatorSize: TabBarIndicatorSize.label,
1620
+ dividerColor: _border,
1621
+ labelStyle: const TextStyle(
1622
+ fontWeight: FontWeight.w700,
1623
+ fontSize: 13,
1624
+ letterSpacing: 0.3,
1625
+ ),
1626
+ tabs: <Widget>[
1627
+ Tab(
1758
1628
  child: Row(
1759
- crossAxisAlignment: CrossAxisAlignment.start,
1629
+ mainAxisSize: MainAxisSize.min,
1760
1630
  children: <Widget>[
1761
- Expanded(
1762
- child: Column(
1763
- crossAxisAlignment: CrossAxisAlignment.start,
1764
- children: <Widget>[
1765
- Text(
1766
- entry.key,
1767
- style: TextStyle(fontWeight: FontWeight.w700),
1768
- ),
1769
- const SizedBox(height: 6),
1770
- Text(entry.value.toString()),
1771
- ],
1772
- ),
1773
- ),
1774
- IconButton(
1775
- onPressed: () => _openCoreMemoryEditor(
1776
- context,
1777
- controller,
1778
- keyValue: entry,
1779
- ),
1780
- icon: Icon(Icons.edit_outlined),
1781
- ),
1782
- IconButton(
1783
- onPressed: () => _confirmDelete(
1784
- context,
1785
- title: 'Delete core memory entry?',
1786
- message:
1787
- 'Remove "${entry.key}" from core memory.',
1788
- onConfirm: () =>
1789
- controller.deleteCoreMemory(entry.key),
1790
- ),
1791
- icon: Icon(Icons.delete_outline),
1792
- ),
1631
+ Icon(Icons.psychology_outlined, size: 16),
1632
+ const SizedBox(width: 6),
1633
+ Text('Memories'),
1793
1634
  ],
1794
1635
  ),
1795
- );
1796
- }),
1797
- ],
1798
- ),
1799
- ),
1800
- ),
1801
- const SizedBox(height: 16),
1802
- Card(
1803
- child: Padding(
1804
- padding: const EdgeInsets.all(18),
1805
- child: Column(
1806
- crossAxisAlignment: CrossAxisAlignment.start,
1807
- children: <Widget>[
1808
- const _SectionTitle('Memories'),
1809
- const SizedBox(height: 6),
1810
- Text(
1811
- showingSearchResults
1812
- ? 'Showing search results. Select memories to archive or delete them together.'
1813
- : 'Select one or more memories to archive or delete them together.',
1814
- style: TextStyle(color: _textSecondary),
1815
- ),
1816
- const SizedBox(height: 10),
1817
- if (memoriesToShow.isNotEmpty)
1818
- Wrap(
1819
- spacing: 10,
1820
- runSpacing: 10,
1821
- crossAxisAlignment: WrapCrossAlignment.center,
1822
- children: <Widget>[
1823
- OutlinedButton.icon(
1824
- onPressed: allVisibleSelected || _bulkActionInFlight
1825
- ? null
1826
- : () => _selectAllVisibleMemories(memoriesToShow),
1827
- icon: Icon(Icons.done_all_outlined),
1828
- label: Text(
1829
- allVisibleSelected ? 'All Selected' : 'Select All',
1830
- ),
1831
- ),
1832
- OutlinedButton.icon(
1833
- onPressed: selectedCount == 0 || _bulkActionInFlight
1834
- ? null
1835
- : _clearMemorySelection,
1836
- icon: Icon(Icons.deselect_outlined),
1837
- label: Text('Clear Selection'),
1636
+ ),
1637
+ Tab(
1638
+ child: Row(
1639
+ mainAxisSize: MainAxisSize.min,
1640
+ children: <Widget>[
1641
+ Icon(Icons.push_pin_outlined, size: 16),
1642
+ const SizedBox(width: 6),
1643
+ Text('Core'),
1644
+ ],
1838
1645
  ),
1839
- if (selectedCount > 0)
1840
- FilledButton.icon(
1841
- onPressed: _bulkActionInFlight
1842
- ? null
1843
- : () => _runBulkMemoryAction(
1844
- title: 'Archive selected memories?',
1845
- message:
1846
- 'Archive $selectedCount selected ${selectedCount == 1 ? 'memory' : 'memories'}? Archived memories are removed from the main list.',
1847
- confirmLabel: 'Archive',
1848
- onConfirm: controller.archiveMemories,
1849
- ),
1850
- icon: Icon(Icons.archive_outlined),
1851
- label: Text('Archive ($selectedCount)'),
1852
- ),
1853
- if (selectedCount > 0)
1854
- OutlinedButton.icon(
1855
- onPressed: _bulkActionInFlight
1856
- ? null
1857
- : () => _runBulkMemoryAction(
1858
- title: 'Delete selected memories?',
1859
- message:
1860
- 'Delete $selectedCount selected ${selectedCount == 1 ? 'memory' : 'memories'} permanently?',
1861
- confirmLabel: 'Delete',
1862
- onConfirm: controller.deleteMemories,
1863
- ),
1864
- icon: Icon(Icons.delete_sweep_outlined),
1865
- label: Text('Delete ($selectedCount)'),
1866
- ),
1867
- ],
1868
- ),
1869
- if (selectedCount > 0) ...<Widget>[
1870
- const SizedBox(height: 10),
1871
- Text(
1872
- '$selectedCount selected',
1873
- style: TextStyle(
1874
- color: _textSecondary,
1875
- fontWeight: FontWeight.w600,
1876
1646
  ),
1877
- ),
1878
- ],
1879
- if (memoriesToShow.isNotEmpty) const SizedBox(height: 10),
1880
- if (memoriesToShow.isEmpty)
1881
- Text(
1882
- 'No memory entries found.',
1883
- style: TextStyle(color: _textSecondary),
1884
- )
1885
- else
1886
- ...memoriesToShow.map((memory) {
1887
- final isSelected = selectedMemoryIds.contains(memory.id);
1888
- return Container(
1889
- width: double.infinity,
1890
- margin: const EdgeInsets.only(bottom: 10),
1891
- decoration: BoxDecoration(
1892
- color: isSelected ? _accentMuted : _bgSecondary,
1893
- borderRadius: BorderRadius.circular(12),
1894
- border: Border.all(
1895
- color: isSelected ? _accent : _border,
1896
- ),
1647
+ Tab(
1648
+ child: Row(
1649
+ mainAxisSize: MainAxisSize.min,
1650
+ children: <Widget>[
1651
+ Icon(Icons.swap_horiz_outlined, size: 16),
1652
+ const SizedBox(width: 6),
1653
+ Text('Transfer'),
1654
+ ],
1897
1655
  ),
1898
- child: Material(
1899
- color: Colors.transparent,
1900
- child: InkWell(
1901
- borderRadius: BorderRadius.circular(12),
1902
- onTap: () =>
1903
- _toggleMemorySelection(memory.id, !isSelected),
1904
- child: Padding(
1905
- padding: const EdgeInsets.all(12),
1906
- child: Row(
1907
- crossAxisAlignment: CrossAxisAlignment.start,
1908
- children: <Widget>[
1909
- Checkbox(
1910
- value: isSelected,
1911
- onChanged: (value) => _toggleMemorySelection(
1912
- memory.id,
1913
- value ?? false,
1656
+ ),
1657
+ ],
1658
+ ),
1659
+ AnimatedBuilder(
1660
+ animation: _tabController,
1661
+ builder: (context, _) {
1662
+ return IndexedStack(
1663
+ index: _tabController.index,
1664
+ children: <Widget>[
1665
+ // --- Memories tab ---
1666
+ Padding(
1667
+ padding: const EdgeInsets.all(18),
1668
+ child: Column(
1669
+ crossAxisAlignment: CrossAxisAlignment.start,
1670
+ children: <Widget>[
1671
+ Row(
1672
+ children: <Widget>[
1673
+ Expanded(
1674
+ child: TextField(
1675
+ controller: _searchController,
1676
+ decoration: const InputDecoration(
1677
+ labelText: 'Search memory',
1678
+ prefixIcon: Icon(
1679
+ Icons.search,
1680
+ size: 18,
1681
+ ),
1682
+ ),
1683
+ onSubmitted: (_) =>
1684
+ _runMemorySearch(controller),
1685
+ ),
1914
1686
  ),
1915
- ),
1916
- const SizedBox(width: 8),
1917
- Expanded(
1918
- child: Column(
1919
- crossAxisAlignment:
1920
- CrossAxisAlignment.start,
1921
- children: <Widget>[
1922
- Row(
1923
- crossAxisAlignment:
1924
- CrossAxisAlignment.start,
1925
- children: <Widget>[
1926
- Expanded(
1927
- child: Wrap(
1928
- spacing: 10,
1929
- runSpacing: 10,
1930
- children: <Widget>[
1931
- _MetaPill(
1932
- label: memory.category,
1933
- icon: Icons.label_outline,
1934
- ),
1935
- _MetaPill(
1936
- label:
1937
- 'Importance ${memory.importance}',
1938
- icon: Icons
1939
- .priority_high_outlined,
1940
- ),
1941
- ],
1942
- ),
1943
- ),
1944
- IconButton(
1945
- onPressed: _bulkActionInFlight
1946
- ? null
1947
- : () => _confirmDelete(
1948
- context,
1949
- title: 'Delete memory?',
1950
- message:
1951
- 'This memory entry will be removed permanently.',
1952
- onConfirm: () =>
1953
- _deleteSingleMemory(
1954
- controller,
1955
- memory.id,
1956
- ),
1957
- ),
1958
- icon: Icon(Icons.delete_outline),
1959
- ),
1960
- ],
1687
+ const SizedBox(width: 10),
1688
+ FilledButton(
1689
+ onPressed: () =>
1690
+ _runMemorySearch(controller),
1691
+ child: Text('Search'),
1692
+ ),
1693
+ if (showingSearchResults ||
1694
+ _entityFilter != null) ...<Widget>[
1695
+ const SizedBox(width: 10),
1696
+ OutlinedButton(
1697
+ onPressed: () =>
1698
+ _resetMemorySearch(controller),
1699
+ child: Text('Reset'),
1700
+ ),
1701
+ ],
1702
+ ],
1703
+ ),
1704
+ if (_entityFilter != null) ...<Widget>[
1705
+ const SizedBox(height: 10),
1706
+ Wrap(
1707
+ spacing: 8,
1708
+ children: <Widget>[
1709
+ _MetaPill(
1710
+ label: 'Entity: $_entityFilter',
1711
+ icon: Icons.filter_alt_outlined,
1712
+ color: _accent,
1713
+ ),
1714
+ ],
1715
+ ),
1716
+ ],
1717
+ if (memoriesToShow.isNotEmpty) ...<Widget>[
1718
+ const SizedBox(height: 12),
1719
+ Wrap(
1720
+ spacing: 8,
1721
+ runSpacing: 8,
1722
+ crossAxisAlignment:
1723
+ WrapCrossAlignment.center,
1724
+ children: <Widget>[
1725
+ OutlinedButton.icon(
1726
+ onPressed: allVisibleSelected ||
1727
+ _bulkActionInFlight
1728
+ ? null
1729
+ : () => _selectAllVisibleMemories(
1730
+ memoriesToShow),
1731
+ icon: Icon(
1732
+ Icons.done_all_outlined,
1733
+ size: 16,
1961
1734
  ),
1962
- const SizedBox(height: 10),
1963
- Text(memory.content),
1964
- const SizedBox(height: 8),
1965
- Text(
1966
- memory.createdAtLabel,
1967
- style: TextStyle(
1968
- fontSize: 12,
1969
- color: _textSecondary,
1735
+ label: Text(
1736
+ allVisibleSelected
1737
+ ? 'All Selected'
1738
+ : 'Select All',
1739
+ ),
1740
+ ),
1741
+ if (selectedCount > 0) ...<Widget>[
1742
+ OutlinedButton.icon(
1743
+ onPressed: _bulkActionInFlight
1744
+ ? null
1745
+ : _clearMemorySelection,
1746
+ icon: Icon(
1747
+ Icons.deselect_outlined,
1748
+ size: 16,
1749
+ ),
1750
+ label: Text('Clear'),
1751
+ ),
1752
+ FilledButton.icon(
1753
+ onPressed: _bulkActionInFlight
1754
+ ? null
1755
+ : () => _runBulkMemoryAction(
1756
+ title:
1757
+ 'Archive selected memories?',
1758
+ message:
1759
+ 'Archive $selectedCount ${selectedCount == 1 ? 'memory' : 'memories'}?',
1760
+ confirmLabel: 'Archive',
1761
+ onConfirm:
1762
+ controller.archiveMemories,
1763
+ ),
1764
+ icon: Icon(
1765
+ Icons.archive_outlined,
1766
+ size: 16,
1767
+ ),
1768
+ label: Text(
1769
+ 'Archive ($selectedCount)',
1770
+ ),
1771
+ ),
1772
+ OutlinedButton.icon(
1773
+ onPressed: _bulkActionInFlight
1774
+ ? null
1775
+ : () => _runBulkMemoryAction(
1776
+ title:
1777
+ 'Delete selected memories?',
1778
+ message:
1779
+ 'Delete $selectedCount ${selectedCount == 1 ? 'memory' : 'memories'} permanently?',
1780
+ confirmLabel: 'Delete',
1781
+ onConfirm:
1782
+ controller.deleteMemories,
1783
+ ),
1784
+ icon: Icon(
1785
+ Icons.delete_sweep_outlined,
1786
+ size: 16,
1787
+ ),
1788
+ label: Text(
1789
+ 'Delete ($selectedCount)',
1970
1790
  ),
1971
1791
  ),
1972
1792
  ],
1973
- ),
1793
+ ],
1974
1794
  ),
1975
1795
  ],
1976
- ),
1796
+ const SizedBox(height: 12),
1797
+ if (memoriesToShow.isEmpty)
1798
+ Text(
1799
+ _entityFilter != null
1800
+ ? 'No memories linked to "$_entityFilter".'
1801
+ : 'No memory entries found.',
1802
+ style: TextStyle(color: _textSecondary),
1803
+ )
1804
+ else
1805
+ ...memoriesToShow.map((memory) {
1806
+ final isSelected =
1807
+ selectedIds.contains(memory.id);
1808
+ return _MemoryRow(
1809
+ memory: memory,
1810
+ isSelected: isSelected,
1811
+ onTap: () => _toggleMemorySelection(
1812
+ memory.id,
1813
+ !isSelected,
1814
+ ),
1815
+ onCheck: (value) =>
1816
+ _toggleMemorySelection(
1817
+ memory.id,
1818
+ value ?? false,
1819
+ ),
1820
+ onDelete: _bulkActionInFlight
1821
+ ? null
1822
+ : () => _confirmDelete(
1823
+ context,
1824
+ title: 'Delete memory?',
1825
+ message:
1826
+ 'This memory will be removed permanently.',
1827
+ onConfirm: () =>
1828
+ _deleteSingleMemory(
1829
+ controller,
1830
+ memory.id,
1831
+ ),
1832
+ ),
1833
+ );
1834
+ }),
1835
+ ],
1977
1836
  ),
1978
1837
  ),
1979
- ),
1980
- );
1981
- }),
1982
- ],
1983
- ),
1984
- ),
1985
- ),
1986
- const SizedBox(height: 16),
1987
- Card(
1988
- child: Padding(
1989
- padding: const EdgeInsets.all(18),
1990
- child: Column(
1991
- crossAxisAlignment: CrossAxisAlignment.start,
1992
- children: <Widget>[
1993
- const _SectionTitle('Recent Conversations'),
1994
- const SizedBox(height: 10),
1995
- if (controller.memoryConversations.isEmpty)
1996
- Text(
1997
- 'No recent conversations found.',
1998
- style: TextStyle(color: _textSecondary),
1999
- )
2000
- else
2001
- ...controller.memoryConversations.map(
2002
- (conversation) => Padding(
2003
- padding: const EdgeInsets.only(bottom: 10),
2004
- child: Container(
2005
- width: double.infinity,
2006
- padding: const EdgeInsets.all(12),
2007
- decoration: BoxDecoration(
2008
- color: _bgSecondary,
2009
- borderRadius: BorderRadius.circular(12),
2010
- border: Border.all(color: _border),
1838
+
1839
+ // --- Core tab ---
1840
+ Padding(
1841
+ padding: const EdgeInsets.all(18),
1842
+ child: Column(
1843
+ crossAxisAlignment: CrossAxisAlignment.start,
1844
+ children: <Widget>[
1845
+ Row(
1846
+ children: <Widget>[
1847
+ Expanded(
1848
+ child: Text(
1849
+ 'Key-value pairs that persist across conversations.',
1850
+ style: TextStyle(
1851
+ color: _textSecondary,
1852
+ ),
1853
+ ),
1854
+ ),
1855
+ TextButton.icon(
1856
+ onPressed: () => _openCoreMemoryEditor(
1857
+ context,
1858
+ controller,
1859
+ ),
1860
+ icon: Icon(Icons.add),
1861
+ label: Text('Add Entry'),
1862
+ ),
1863
+ ],
1864
+ ),
1865
+ const SizedBox(height: 10),
1866
+ if (controller
1867
+ .memoryOverview.coreEntries.isEmpty)
1868
+ Text(
1869
+ 'No core memory entries yet.',
1870
+ style: TextStyle(color: _textSecondary),
1871
+ )
1872
+ else
1873
+ ...controller.memoryOverview.coreEntries
1874
+ .entries
1875
+ .map((entry) {
1876
+ return Container(
1877
+ width: double.infinity,
1878
+ margin: const EdgeInsets.only(bottom: 10),
1879
+ padding: const EdgeInsets.all(12),
1880
+ decoration: BoxDecoration(
1881
+ color: _bgSecondary,
1882
+ borderRadius:
1883
+ BorderRadius.circular(12),
1884
+ border: Border.all(color: _border),
1885
+ ),
1886
+ child: Row(
1887
+ crossAxisAlignment:
1888
+ CrossAxisAlignment.start,
1889
+ children: <Widget>[
1890
+ Expanded(
1891
+ child: Column(
1892
+ crossAxisAlignment:
1893
+ CrossAxisAlignment.start,
1894
+ children: <Widget>[
1895
+ Text(
1896
+ entry.key,
1897
+ style: TextStyle(
1898
+ fontWeight: FontWeight.w700,
1899
+ ),
1900
+ ),
1901
+ const SizedBox(height: 6),
1902
+ Text(
1903
+ entry.value.toString(),
1904
+ ),
1905
+ ],
1906
+ ),
1907
+ ),
1908
+ IconButton(
1909
+ onPressed: () =>
1910
+ _openCoreMemoryEditor(
1911
+ context,
1912
+ controller,
1913
+ keyValue: entry,
1914
+ ),
1915
+ icon: Icon(Icons.edit_outlined),
1916
+ ),
1917
+ IconButton(
1918
+ onPressed: () => _confirmDelete(
1919
+ context,
1920
+ title:
1921
+ 'Delete core memory entry?',
1922
+ message:
1923
+ 'Remove "${entry.key}" from core memory.',
1924
+ onConfirm: () => controller
1925
+ .deleteCoreMemory(
1926
+ entry.key,
1927
+ ),
1928
+ ),
1929
+ icon: Icon(Icons.delete_outline),
1930
+ ),
1931
+ ],
1932
+ ),
1933
+ );
1934
+ }),
1935
+ ],
1936
+ ),
2011
1937
  ),
2012
- child: Column(
2013
- crossAxisAlignment: CrossAxisAlignment.start,
2014
- children: <Widget>[
2015
- Text(
2016
- conversation.title,
2017
- style: TextStyle(fontWeight: FontWeight.w700),
2018
- ),
2019
- const SizedBox(height: 8),
2020
- Text(
2021
- conversation.preview,
2022
- style: TextStyle(color: _textSecondary),
2023
- ),
2024
- ],
1938
+
1939
+ // --- Transfer tab ---
1940
+ Padding(
1941
+ padding: const EdgeInsets.all(18),
1942
+ child: Column(
1943
+ crossAxisAlignment: CrossAxisAlignment.start,
1944
+ children: <Widget>[
1945
+ Text(
1946
+ 'Generate a prompt for another AI, paste the response here to import memories.',
1947
+ style: TextStyle(color: _textSecondary),
1948
+ ),
1949
+ const SizedBox(height: 12),
1950
+ Wrap(
1951
+ spacing: 10,
1952
+ runSpacing: 10,
1953
+ children: <Widget>[
1954
+ FilledButton.icon(
1955
+ onPressed: _llmPromptLoading
1956
+ ? null
1957
+ : () =>
1958
+ _loadLlmPrompt(controller),
1959
+ icon: Icon(
1960
+ Icons.auto_awesome_outlined,
1961
+ ),
1962
+ label: Text(
1963
+ _llmPromptLoading
1964
+ ? 'Generating...'
1965
+ : 'Generate Prompt',
1966
+ ),
1967
+ ),
1968
+ OutlinedButton.icon(
1969
+ onPressed:
1970
+ _llmPromptController.text
1971
+ .trim()
1972
+ .isEmpty
1973
+ ? null
1974
+ : _copyLlmPrompt,
1975
+ icon: Icon(Icons.copy_all_outlined),
1976
+ label: Text('Copy Prompt'),
1977
+ ),
1978
+ ],
1979
+ ),
1980
+ const SizedBox(height: 12),
1981
+ TextField(
1982
+ controller: _llmPromptController,
1983
+ minLines: 4,
1984
+ maxLines: 8,
1985
+ readOnly: true,
1986
+ decoration: const InputDecoration(
1987
+ labelText:
1988
+ 'Prompt to paste into another AI',
1989
+ ),
1990
+ ),
1991
+ const SizedBox(height: 16),
1992
+ SwitchListTile.adaptive(
1993
+ contentPadding: EdgeInsets.zero,
1994
+ value: _llmApplyBehaviorNotes,
1995
+ onChanged: _llmImporting
1996
+ ? null
1997
+ : (value) => setState(
1998
+ () =>
1999
+ _llmApplyBehaviorNotes = value,
2000
+ ),
2001
+ title: Text('Apply behavior notes'),
2002
+ subtitle: Text(
2003
+ 'Overwrite behavior notes from the import.',
2004
+ ),
2005
+ ),
2006
+ SwitchListTile.adaptive(
2007
+ contentPadding: EdgeInsets.zero,
2008
+ value: _llmApplyCoreMemory,
2009
+ onChanged: _llmImporting
2010
+ ? null
2011
+ : (value) => setState(
2012
+ () =>
2013
+ _llmApplyCoreMemory = value,
2014
+ ),
2015
+ title: Text('Apply core memory'),
2016
+ subtitle: Text(
2017
+ 'Update core memory entries from the import.',
2018
+ ),
2019
+ ),
2020
+ const SizedBox(height: 12),
2021
+ TextField(
2022
+ controller: _llmImportController,
2023
+ minLines: 4,
2024
+ maxLines: 10,
2025
+ decoration: const InputDecoration(
2026
+ labelText: 'LLM memory export response',
2027
+ ),
2028
+ ),
2029
+ const SizedBox(height: 12),
2030
+ FilledButton.icon(
2031
+ onPressed: _llmImporting
2032
+ ? null
2033
+ : () =>
2034
+ _importLlmMemories(controller),
2035
+ icon: Icon(Icons.file_download_outlined),
2036
+ label: Text(
2037
+ _llmImporting
2038
+ ? 'Importing...'
2039
+ : 'Import',
2040
+ ),
2041
+ ),
2042
+ ],
2043
+ ),
2025
2044
  ),
2026
- ),
2027
- ),
2028
- ),
2045
+ ],
2046
+ );
2047
+ },
2048
+ ),
2029
2049
  ],
2030
2050
  ),
2031
2051
  ),
@@ -2075,9 +2095,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
2075
2095
  ],
2076
2096
  decoration: const InputDecoration(labelText: 'Category'),
2077
2097
  onChanged: (value) {
2078
- if (value != null) {
2079
- category = value;
2080
- }
2098
+ if (value != null) category = value;
2081
2099
  },
2082
2100
  ),
2083
2101
  const SizedBox(height: 12),
@@ -2108,9 +2126,7 @@ class _MemoryPanelState extends State<MemoryPanel> {
2108
2126
  importance:
2109
2127
  int.tryParse(importanceController.text.trim()) ?? 5,
2110
2128
  );
2111
- if (context.mounted) {
2112
- Navigator.of(context).pop();
2113
- }
2129
+ if (context.mounted) Navigator.of(context).pop();
2114
2130
  },
2115
2131
  child: Text('Save'),
2116
2132
  ),
@@ -2154,9 +2170,66 @@ class _MemoryPanelState extends State<MemoryPanel> {
2154
2170
  await controller.updateAssistantBehaviorNotes(
2155
2171
  contentController.text,
2156
2172
  );
2157
- if (context.mounted) {
2158
- Navigator.of(context).pop();
2159
- }
2173
+ if (context.mounted) Navigator.of(context).pop();
2174
+ },
2175
+ child: Text('Save'),
2176
+ ),
2177
+ ],
2178
+ );
2179
+ },
2180
+ );
2181
+ }
2182
+
2183
+ Future<void> _openCoreMemoryEditor(
2184
+ BuildContext context,
2185
+ NeoAgentController controller, {
2186
+ MapEntry<String, dynamic>? keyValue,
2187
+ }) async {
2188
+ final keyController = TextEditingController(text: keyValue?.key ?? '');
2189
+ final valueController = TextEditingController(
2190
+ text: keyValue?.value?.toString() ?? '',
2191
+ );
2192
+ await showDialog<void>(
2193
+ context: context,
2194
+ builder: (context) {
2195
+ return AlertDialog(
2196
+ backgroundColor: _bgCard,
2197
+ title: Text(
2198
+ keyValue == null
2199
+ ? 'Add Core Memory Entry'
2200
+ : 'Edit Core Memory Entry',
2201
+ ),
2202
+ content: SizedBox(
2203
+ width: 620,
2204
+ child: Column(
2205
+ mainAxisSize: MainAxisSize.min,
2206
+ children: <Widget>[
2207
+ TextField(
2208
+ controller: keyController,
2209
+ decoration: const InputDecoration(labelText: 'Key'),
2210
+ ),
2211
+ const SizedBox(height: 12),
2212
+ TextField(
2213
+ controller: valueController,
2214
+ minLines: 3,
2215
+ maxLines: 8,
2216
+ decoration: const InputDecoration(labelText: 'Value'),
2217
+ ),
2218
+ ],
2219
+ ),
2220
+ ),
2221
+ actions: <Widget>[
2222
+ TextButton(
2223
+ onPressed: () => Navigator.of(context).pop(),
2224
+ child: Text('Cancel'),
2225
+ ),
2226
+ FilledButton(
2227
+ onPressed: () async {
2228
+ await controller.updateCoreMemory(
2229
+ keyController.text.trim(),
2230
+ valueController.text.trim(),
2231
+ );
2232
+ if (context.mounted) Navigator.of(context).pop();
2160
2233
  },
2161
2234
  child: Text('Save'),
2162
2235
  ),
@@ -2165,69 +2238,682 @@ class _MemoryPanelState extends State<MemoryPanel> {
2165
2238
  },
2166
2239
  );
2167
2240
  }
2241
+ }
2242
+
2243
+ class _MemoryStatChip extends StatelessWidget {
2244
+ const _MemoryStatChip({
2245
+ required this.label,
2246
+ required this.caption,
2247
+ required this.icon,
2248
+ });
2249
+
2250
+ final String label;
2251
+ final String caption;
2252
+ final IconData icon;
2253
+
2254
+ @override
2255
+ Widget build(BuildContext context) {
2256
+ return _GlassSurface(
2257
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
2258
+ borderRadius: BorderRadius.circular(14),
2259
+ blurSigma: 10,
2260
+ fillColor: _bgSecondary.withValues(alpha: 0.7),
2261
+ borderColor: _borderLight,
2262
+ child: Row(
2263
+ mainAxisSize: MainAxisSize.min,
2264
+ children: <Widget>[
2265
+ Icon(icon, size: 15, color: _accentAlt),
2266
+ const SizedBox(width: 8),
2267
+ Column(
2268
+ crossAxisAlignment: CrossAxisAlignment.start,
2269
+ mainAxisSize: MainAxisSize.min,
2270
+ children: <Widget>[
2271
+ Text(
2272
+ label,
2273
+ style: TextStyle(
2274
+ fontWeight: FontWeight.w800,
2275
+ fontSize: 15,
2276
+ letterSpacing: -0.3,
2277
+ color: _textPrimary,
2278
+ ),
2279
+ ),
2280
+ Text(
2281
+ caption,
2282
+ style: TextStyle(
2283
+ fontSize: 10,
2284
+ fontWeight: FontWeight.w600,
2285
+ color: _textSecondary,
2286
+ letterSpacing: 0.2,
2287
+ ),
2288
+ ),
2289
+ ],
2290
+ ),
2291
+ ],
2292
+ ),
2293
+ );
2294
+ }
2295
+ }
2296
+
2297
+ class _MemoryConfidenceGauge extends StatefulWidget {
2298
+ const _MemoryConfidenceGauge({required this.confidence});
2299
+
2300
+ final double confidence;
2301
+
2302
+ @override
2303
+ State<_MemoryConfidenceGauge> createState() => _MemoryConfidenceGaugeState();
2304
+ }
2305
+
2306
+ class _MemoryConfidenceGaugeState extends State<_MemoryConfidenceGauge>
2307
+ with SingleTickerProviderStateMixin {
2308
+ late final AnimationController _controller;
2309
+ late Animation<double> _progress;
2310
+
2311
+ @override
2312
+ void initState() {
2313
+ super.initState();
2314
+ _controller = AnimationController(
2315
+ vsync: this,
2316
+ duration: const Duration(milliseconds: 1200),
2317
+ );
2318
+ _progress = Tween<double>(begin: 0, end: widget.confidence).animate(
2319
+ CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
2320
+ );
2321
+ _controller.forward();
2322
+ }
2323
+
2324
+ @override
2325
+ void didUpdateWidget(_MemoryConfidenceGauge oldWidget) {
2326
+ super.didUpdateWidget(oldWidget);
2327
+ if (oldWidget.confidence != widget.confidence) {
2328
+ _progress = Tween<double>(
2329
+ begin: _progress.value,
2330
+ end: widget.confidence,
2331
+ ).animate(
2332
+ CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
2333
+ );
2334
+ _controller
2335
+ ..reset()
2336
+ ..forward();
2337
+ }
2338
+ }
2339
+
2340
+ @override
2341
+ void dispose() {
2342
+ _controller.dispose();
2343
+ super.dispose();
2344
+ }
2345
+
2346
+ @override
2347
+ Widget build(BuildContext context) {
2348
+ return AnimatedBuilder(
2349
+ animation: _progress,
2350
+ builder: (context, child) {
2351
+ final value = _progress.value;
2352
+ final percent = (value * 100).round();
2353
+ return SizedBox(
2354
+ width: 64,
2355
+ height: 64,
2356
+ child: CustomPaint(
2357
+ painter: _RadialGaugePainter(
2358
+ progress: value,
2359
+ trackColor: _border,
2360
+ fillColor: _accent,
2361
+ glowColor: _accentHover,
2362
+ ),
2363
+ child: Center(
2364
+ child: Text(
2365
+ '$percent%',
2366
+ style: TextStyle(
2367
+ fontSize: 13,
2368
+ fontWeight: FontWeight.w800,
2369
+ color: _textPrimary,
2370
+ letterSpacing: -0.5,
2371
+ ),
2372
+ ),
2373
+ ),
2374
+ ),
2375
+ );
2376
+ },
2377
+ );
2378
+ }
2379
+ }
2168
2380
 
2169
- Future<void> _openCoreMemoryEditor(
2170
- BuildContext context,
2171
- NeoAgentController controller, {
2172
- MapEntry<String, dynamic>? keyValue,
2173
- }) async {
2174
- final keyController = TextEditingController(text: keyValue?.key ?? '');
2175
- final valueController = TextEditingController(
2176
- text: keyValue?.value?.toString() ?? '',
2381
+ class _RadialGaugePainter extends CustomPainter {
2382
+ const _RadialGaugePainter({
2383
+ required this.progress,
2384
+ required this.trackColor,
2385
+ required this.fillColor,
2386
+ required this.glowColor,
2387
+ });
2388
+
2389
+ final double progress;
2390
+ final Color trackColor;
2391
+ final Color fillColor;
2392
+ final Color glowColor;
2393
+
2394
+ @override
2395
+ void paint(Canvas canvas, Size size) {
2396
+ final center = Offset(size.width / 2, size.height / 2);
2397
+ final radius = size.width / 2 - 5;
2398
+ const startAngle = -math.pi / 2;
2399
+ const totalAngle = 2 * math.pi;
2400
+ final sweepAngle = totalAngle * progress.clamp(0.0, 1.0);
2401
+
2402
+ final trackPaint = Paint()
2403
+ ..color = trackColor
2404
+ ..style = PaintingStyle.stroke
2405
+ ..strokeWidth = 5
2406
+ ..strokeCap = StrokeCap.round;
2407
+ canvas.drawArc(
2408
+ Rect.fromCircle(center: center, radius: radius),
2409
+ startAngle,
2410
+ totalAngle,
2411
+ false,
2412
+ trackPaint,
2177
2413
  );
2178
- await showDialog<void>(
2179
- context: context,
2180
- builder: (context) {
2181
- return AlertDialog(
2182
- backgroundColor: _bgCard,
2183
- title: Text(
2184
- keyValue == null
2185
- ? 'Add Core Memory Entry'
2186
- : 'Edit Core Memory Entry',
2187
- ),
2188
- content: SizedBox(
2189
- width: 620,
2190
- child: Column(
2191
- mainAxisSize: MainAxisSize.min,
2414
+
2415
+ if (sweepAngle > 0) {
2416
+ final glowPaint = Paint()
2417
+ ..color = glowColor.withValues(alpha: 0.25)
2418
+ ..style = PaintingStyle.stroke
2419
+ ..strokeWidth = 10
2420
+ ..strokeCap = StrokeCap.round
2421
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
2422
+ canvas.drawArc(
2423
+ Rect.fromCircle(center: center, radius: radius),
2424
+ startAngle,
2425
+ sweepAngle,
2426
+ false,
2427
+ glowPaint,
2428
+ );
2429
+
2430
+ final fillPaint = Paint()
2431
+ ..shader = SweepGradient(
2432
+ startAngle: startAngle,
2433
+ endAngle: startAngle + sweepAngle,
2434
+ colors: <Color>[fillColor, glowColor],
2435
+ ).createShader(Rect.fromCircle(center: center, radius: radius))
2436
+ ..style = PaintingStyle.stroke
2437
+ ..strokeWidth = 5
2438
+ ..strokeCap = StrokeCap.round;
2439
+ canvas.drawArc(
2440
+ Rect.fromCircle(center: center, radius: radius),
2441
+ startAngle,
2442
+ sweepAngle,
2443
+ false,
2444
+ fillPaint,
2445
+ );
2446
+ }
2447
+ }
2448
+
2449
+ @override
2450
+ bool shouldRepaint(_RadialGaugePainter oldDelegate) =>
2451
+ progress != oldDelegate.progress ||
2452
+ trackColor != oldDelegate.trackColor ||
2453
+ fillColor != oldDelegate.fillColor;
2454
+ }
2455
+
2456
+ class _MemoryRow extends StatelessWidget {
2457
+ const _MemoryRow({
2458
+ required this.memory,
2459
+ required this.isSelected,
2460
+ required this.onTap,
2461
+ required this.onCheck,
2462
+ this.onDelete,
2463
+ });
2464
+
2465
+ final MemoryItem memory;
2466
+ final bool isSelected;
2467
+ final VoidCallback onTap;
2468
+ final ValueChanged<bool?> onCheck;
2469
+ final VoidCallback? onDelete;
2470
+
2471
+ @override
2472
+ Widget build(BuildContext context) {
2473
+ return Container(
2474
+ width: double.infinity,
2475
+ margin: const EdgeInsets.only(bottom: 8),
2476
+ decoration: BoxDecoration(
2477
+ color: isSelected ? _accentMuted : _bgSecondary,
2478
+ borderRadius: BorderRadius.circular(12),
2479
+ border: Border.all(color: isSelected ? _accent : _border),
2480
+ ),
2481
+ child: Material(
2482
+ color: Colors.transparent,
2483
+ child: InkWell(
2484
+ borderRadius: BorderRadius.circular(12),
2485
+ onTap: onTap,
2486
+ child: Padding(
2487
+ padding: const EdgeInsets.all(12),
2488
+ child: Row(
2489
+ crossAxisAlignment: CrossAxisAlignment.start,
2192
2490
  children: <Widget>[
2193
- TextField(
2194
- controller: keyController,
2195
- decoration: const InputDecoration(labelText: 'Key'),
2196
- ),
2197
- const SizedBox(height: 12),
2198
- TextField(
2199
- controller: valueController,
2200
- minLines: 3,
2201
- maxLines: 8,
2202
- decoration: const InputDecoration(labelText: 'Value'),
2491
+ Checkbox(value: isSelected, onChanged: onCheck),
2492
+ const SizedBox(width: 6),
2493
+ Expanded(
2494
+ child: Column(
2495
+ crossAxisAlignment: CrossAxisAlignment.start,
2496
+ children: <Widget>[
2497
+ Row(
2498
+ crossAxisAlignment: CrossAxisAlignment.start,
2499
+ children: <Widget>[
2500
+ Expanded(
2501
+ child: Wrap(
2502
+ spacing: 8,
2503
+ runSpacing: 8,
2504
+ children: <Widget>[
2505
+ _MetaPill(
2506
+ label: memory.category,
2507
+ icon: Icons.label_outline,
2508
+ ),
2509
+ _MetaPill(
2510
+ label: 'Imp ${memory.importance}',
2511
+ icon: Icons.priority_high_outlined,
2512
+ ),
2513
+ _MetaPill(
2514
+ label: '${memory.confidencePercent}%',
2515
+ icon: Icons.verified_outlined,
2516
+ ),
2517
+ ],
2518
+ ),
2519
+ ),
2520
+ if (onDelete != null)
2521
+ IconButton(
2522
+ onPressed: onDelete,
2523
+ icon: Icon(
2524
+ Icons.delete_outline,
2525
+ size: 18,
2526
+ ),
2527
+ ),
2528
+ ],
2529
+ ),
2530
+ const SizedBox(height: 8),
2531
+ Text(memory.content),
2532
+ if (memory.entities.isNotEmpty) ...<Widget>[
2533
+ const SizedBox(height: 6),
2534
+ Wrap(
2535
+ spacing: 6,
2536
+ runSpacing: 6,
2537
+ children: memory.entities
2538
+ .take(5)
2539
+ .map(
2540
+ (e) => _MetaPill(
2541
+ label: e.name,
2542
+ icon: Icons.hub_outlined,
2543
+ ),
2544
+ )
2545
+ .toList(),
2546
+ ),
2547
+ ],
2548
+ const SizedBox(height: 6),
2549
+ Text(
2550
+ memory.createdAtLabel,
2551
+ style: TextStyle(
2552
+ fontSize: 11,
2553
+ color: _textMuted,
2554
+ ),
2555
+ ),
2556
+ ],
2557
+ ),
2203
2558
  ),
2204
2559
  ],
2205
2560
  ),
2206
2561
  ),
2207
- actions: <Widget>[
2208
- TextButton(
2209
- onPressed: () => Navigator.of(context).pop(),
2210
- child: Text('Cancel'),
2211
- ),
2212
- FilledButton(
2213
- onPressed: () async {
2214
- await controller.updateCoreMemory(
2215
- keyController.text.trim(),
2216
- valueController.text.trim(),
2562
+ ),
2563
+ ),
2564
+ );
2565
+ }
2566
+ }
2567
+
2568
+ // ---------------------------------------------------------------------------
2569
+ // Interactive Entity Knowledge Graph
2570
+ // ---------------------------------------------------------------------------
2571
+
2572
+ class _EntityGraphView extends StatefulWidget {
2573
+ const _EntityGraphView({
2574
+ required this.entities,
2575
+ required this.knowledgeViews,
2576
+ this.selectedEntity,
2577
+ this.onEntityTapped,
2578
+ });
2579
+
2580
+ final List<MemoryEntity> entities;
2581
+ final List<KnowledgeViewItem> knowledgeViews;
2582
+ final String? selectedEntity;
2583
+ final ValueChanged<String>? onEntityTapped;
2584
+
2585
+ @override
2586
+ State<_EntityGraphView> createState() => _EntityGraphViewState();
2587
+ }
2588
+
2589
+ class _EntityGraphViewState extends State<_EntityGraphView>
2590
+ with SingleTickerProviderStateMixin {
2591
+ late final AnimationController _idleController;
2592
+ final List<_GraphNode> _nodes = <_GraphNode>[];
2593
+ String? _hoveredNode;
2594
+ bool _layoutDone = false;
2595
+ Size? _lastLayoutSize;
2596
+
2597
+ @override
2598
+ void initState() {
2599
+ super.initState();
2600
+ _idleController = AnimationController(
2601
+ vsync: this,
2602
+ duration: const Duration(seconds: 6),
2603
+ )..repeat();
2604
+ _buildNodes();
2605
+ }
2606
+
2607
+ @override
2608
+ void didUpdateWidget(_EntityGraphView oldWidget) {
2609
+ super.didUpdateWidget(oldWidget);
2610
+ if (oldWidget.entities != widget.entities ||
2611
+ oldWidget.knowledgeViews != widget.knowledgeViews) {
2612
+ _buildNodes();
2613
+ }
2614
+ }
2615
+
2616
+ @override
2617
+ void dispose() {
2618
+ _idleController.dispose();
2619
+ super.dispose();
2620
+ }
2621
+
2622
+ static const Map<String, Color> _kindColors = <String, Color>{
2623
+ 'person': Color(0xFF9B8AE0),
2624
+ 'project': Color(0xFF5E9B7C),
2625
+ 'file': Color(0xFF7BA5C7),
2626
+ 'concept': Color(0xFFB8A06B),
2627
+ 'tool': Color(0xFFCF8F6B),
2628
+ 'organization': Color(0xFF7DA0B5),
2629
+ };
2630
+
2631
+ void _buildNodes() {
2632
+ _nodes.clear();
2633
+ final entities = widget.entities;
2634
+ final views = widget.knowledgeViews;
2635
+ final maxMention = entities.fold<int>(
2636
+ 1,
2637
+ (max, e) => e.mentionCount > max ? e.mentionCount : max,
2638
+ );
2639
+
2640
+ for (int i = 0; i < entities.length; i++) {
2641
+ final entity = entities[i];
2642
+ final sizeFactor = 0.4 + 0.6 * (entity.mentionCount / maxMention);
2643
+ _nodes.add(_GraphNode(
2644
+ id: entity.name,
2645
+ label: entity.name,
2646
+ radius: 18 + 20 * sizeFactor,
2647
+ color: _kindColors[entity.kind] ?? _kindColors['concept']!,
2648
+ kind: entity.kind,
2649
+ isReflection: false,
2650
+ offsetPhase: i * 0.7,
2651
+ ));
2652
+ }
2653
+
2654
+ for (int i = 0; i < views.length && i < 6; i++) {
2655
+ _nodes.add(_GraphNode(
2656
+ id: 'kv_${views[i].title}',
2657
+ label: views[i].title,
2658
+ radius: 14,
2659
+ color: const Color(0xFF8B7EC8),
2660
+ kind: views[i].viewType,
2661
+ isReflection: true,
2662
+ offsetPhase: (entities.length + i) * 0.9,
2663
+ ));
2664
+ }
2665
+
2666
+ _layoutDone = false;
2667
+ }
2668
+
2669
+ void _layoutNodes(Size size) {
2670
+ if (_nodes.isEmpty) return;
2671
+ if (_layoutDone && _lastLayoutSize == size) return;
2672
+ _layoutDone = true;
2673
+ _lastLayoutSize = size;
2674
+
2675
+ final cx = size.width / 2;
2676
+ final cy = size.height / 2;
2677
+ final radiusX = size.width * 0.35;
2678
+ final radiusY = size.height * 0.35;
2679
+
2680
+ for (int i = 0; i < _nodes.length; i++) {
2681
+ final angle = (2 * math.pi * i / _nodes.length) - math.pi / 2;
2682
+ final jitter = (i.isEven ? 0.85 : 1.0) + (i % 3) * 0.05;
2683
+ _nodes[i].x = cx + radiusX * jitter * math.cos(angle);
2684
+ _nodes[i].y = cy + radiusY * jitter * math.sin(angle);
2685
+ }
2686
+ }
2687
+
2688
+ void _handleTap(Offset localPosition) {
2689
+ for (final node in _nodes.reversed) {
2690
+ final dx = localPosition.dx - node.x;
2691
+ final dy = localPosition.dy - node.y;
2692
+ if (dx * dx + dy * dy <= node.radius * node.radius * 1.8) {
2693
+ if (!node.isReflection) {
2694
+ widget.onEntityTapped?.call(node.label);
2695
+ }
2696
+ return;
2697
+ }
2698
+ }
2699
+ }
2700
+
2701
+ void _handleHover(Offset? localPosition) {
2702
+ if (localPosition == null) {
2703
+ if (_hoveredNode != null) setState(() => _hoveredNode = null);
2704
+ return;
2705
+ }
2706
+ String? found;
2707
+ for (final node in _nodes.reversed) {
2708
+ final dx = localPosition.dx - node.x;
2709
+ final dy = localPosition.dy - node.y;
2710
+ if (dx * dx + dy * dy <= node.radius * node.radius * 1.8) {
2711
+ found = node.id;
2712
+ break;
2713
+ }
2714
+ }
2715
+ if (found != _hoveredNode) setState(() => _hoveredNode = found);
2716
+ }
2717
+
2718
+ @override
2719
+ Widget build(BuildContext context) {
2720
+ return LayoutBuilder(
2721
+ builder: (context, constraints) {
2722
+ final size = Size(constraints.maxWidth, constraints.maxHeight);
2723
+ _layoutNodes(size);
2724
+ return MouseRegion(
2725
+ onHover: (event) => _handleHover(event.localPosition),
2726
+ onExit: (_) => _handleHover(null),
2727
+ child: GestureDetector(
2728
+ onTapDown: (details) => _handleTap(details.localPosition),
2729
+ child: AnimatedBuilder(
2730
+ animation: _idleController,
2731
+ builder: (context, _) {
2732
+ return CustomPaint(
2733
+ size: size,
2734
+ painter: _EntityGraphPainter(
2735
+ nodes: _nodes,
2736
+ selectedEntity: widget.selectedEntity,
2737
+ hoveredNode: _hoveredNode,
2738
+ animationValue: _idleController.value,
2739
+ accentColor: _accent,
2740
+ bgColor: _bgSecondary,
2741
+ textColor: _textPrimary,
2742
+ mutedTextColor: _textSecondary,
2743
+ borderColor: _border,
2744
+ ),
2217
2745
  );
2218
- if (context.mounted) {
2219
- Navigator.of(context).pop();
2220
- }
2221
2746
  },
2222
- child: Text('Save'),
2223
2747
  ),
2224
- ],
2748
+ ),
2225
2749
  );
2226
2750
  },
2227
2751
  );
2228
2752
  }
2229
2753
  }
2230
2754
 
2755
+ class _GraphNode {
2756
+ _GraphNode({
2757
+ required this.id,
2758
+ required this.label,
2759
+ required this.radius,
2760
+ required this.color,
2761
+ required this.kind,
2762
+ required this.isReflection,
2763
+ required this.offsetPhase,
2764
+ });
2765
+
2766
+ final String id;
2767
+ final String label;
2768
+ final double radius;
2769
+ final Color color;
2770
+ final String kind;
2771
+ final bool isReflection;
2772
+ final double offsetPhase;
2773
+ double x = 0;
2774
+ double y = 0;
2775
+ }
2776
+
2777
+ class _EntityGraphPainter extends CustomPainter {
2778
+ const _EntityGraphPainter({
2779
+ required this.nodes,
2780
+ required this.selectedEntity,
2781
+ required this.hoveredNode,
2782
+ required this.animationValue,
2783
+ required this.accentColor,
2784
+ required this.bgColor,
2785
+ required this.textColor,
2786
+ required this.mutedTextColor,
2787
+ required this.borderColor,
2788
+ });
2789
+
2790
+ final List<_GraphNode> nodes;
2791
+ final String? selectedEntity;
2792
+ final String? hoveredNode;
2793
+ final double animationValue;
2794
+ final Color accentColor;
2795
+ final Color bgColor;
2796
+ final Color textColor;
2797
+ final Color mutedTextColor;
2798
+ final Color borderColor;
2799
+
2800
+ @override
2801
+ void paint(Canvas canvas, Size size) {
2802
+ if (nodes.isEmpty) return;
2803
+
2804
+ final entityNodes =
2805
+ nodes.where((n) => !n.isReflection).toList(growable: false);
2806
+ final reflectionNodes =
2807
+ nodes.where((n) => n.isReflection).toList(growable: false);
2808
+
2809
+ // Draw connections between entity nodes (subtle web)
2810
+ final linePaint = Paint()
2811
+ ..color = borderColor.withValues(alpha: 0.18)
2812
+ ..strokeWidth = 1;
2813
+ for (int i = 0; i < entityNodes.length; i++) {
2814
+ for (int j = i + 1; j < entityNodes.length; j++) {
2815
+ if ((i + j) % 3 != 0) continue;
2816
+ final a = entityNodes[i];
2817
+ final b = entityNodes[j];
2818
+ final drift = math.sin(animationValue * 2 * math.pi + a.offsetPhase);
2819
+ canvas.drawLine(
2820
+ Offset(a.x, a.y + drift * 2),
2821
+ Offset(b.x, b.y + drift * 2),
2822
+ linePaint,
2823
+ );
2824
+ }
2825
+ }
2826
+
2827
+ // Draw connections from reflections to closest entity
2828
+ if (entityNodes.isNotEmpty) {
2829
+ final reflectionLinePaint = Paint()
2830
+ ..color = borderColor.withValues(alpha: 0.14)
2831
+ ..strokeWidth = 1
2832
+ ..style = PaintingStyle.stroke;
2833
+ for (final rn in reflectionNodes) {
2834
+ final drift =
2835
+ math.sin(animationValue * 2 * math.pi + rn.offsetPhase) * 3;
2836
+ var closest = entityNodes.first;
2837
+ var minDist = double.infinity;
2838
+ for (final en in entityNodes) {
2839
+ final d = (en.x - rn.x) * (en.x - rn.x) +
2840
+ (en.y - rn.y) * (en.y - rn.y);
2841
+ if (d < minDist) {
2842
+ minDist = d;
2843
+ closest = en;
2844
+ }
2845
+ }
2846
+ canvas.drawLine(
2847
+ Offset(rn.x, rn.y + drift),
2848
+ Offset(closest.x, closest.y + drift),
2849
+ reflectionLinePaint,
2850
+ );
2851
+ }
2852
+ }
2853
+
2854
+ // Draw nodes
2855
+ for (final node in nodes) {
2856
+ final drift =
2857
+ math.sin(animationValue * 2 * math.pi + node.offsetPhase) * 3;
2858
+ final isSelected = node.label == selectedEntity;
2859
+ final isHovered = node.id == hoveredNode;
2860
+ final cx = node.x;
2861
+ final cy = node.y + drift;
2862
+ final r = node.radius * (isHovered ? 1.15 : 1.0);
2863
+
2864
+ // Glow
2865
+ if (isSelected || isHovered) {
2866
+ final glowPaint = Paint()
2867
+ ..color = (isSelected ? accentColor : node.color)
2868
+ .withValues(alpha: 0.22)
2869
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
2870
+ canvas.drawCircle(Offset(cx, cy), r + 6, glowPaint);
2871
+ }
2872
+
2873
+ // Fill
2874
+ final fillPaint = Paint()
2875
+ ..shader = RadialGradient(
2876
+ colors: <Color>[
2877
+ node.color.withValues(alpha: isSelected ? 0.9 : 0.7),
2878
+ node.color.withValues(alpha: isSelected ? 0.6 : 0.35),
2879
+ ],
2880
+ ).createShader(Rect.fromCircle(center: Offset(cx, cy), radius: r));
2881
+ canvas.drawCircle(Offset(cx, cy), r, fillPaint);
2882
+
2883
+ // Border
2884
+ final borderPaint = Paint()
2885
+ ..color = isSelected
2886
+ ? accentColor
2887
+ : (isHovered
2888
+ ? node.color.withValues(alpha: 0.8)
2889
+ : node.color.withValues(alpha: 0.35))
2890
+ ..style = PaintingStyle.stroke
2891
+ ..strokeWidth = isSelected ? 2.5 : 1.5;
2892
+ canvas.drawCircle(Offset(cx, cy), r, borderPaint);
2893
+
2894
+ // Label
2895
+ final labelStyle = TextStyle(
2896
+ color: isSelected ? textColor : mutedTextColor,
2897
+ fontSize: node.isReflection ? 9 : 11,
2898
+ fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
2899
+ );
2900
+ final tp = TextPainter(
2901
+ text: TextSpan(text: node.label, style: labelStyle),
2902
+ textDirection: TextDirection.ltr,
2903
+ maxLines: 1,
2904
+ ellipsis: '…',
2905
+ )..layout(maxWidth: r * 3);
2906
+ tp.paint(
2907
+ canvas,
2908
+ Offset(cx - tp.width / 2, cy + r + 5),
2909
+ );
2910
+ }
2911
+ }
2912
+
2913
+ @override
2914
+ bool shouldRepaint(_EntityGraphPainter oldDelegate) => true;
2915
+ }
2916
+
2231
2917
  class WidgetsPanel extends StatelessWidget {
2232
2918
  const WidgetsPanel({super.key, required this.controller});
2233
2919
 
@@ -4424,6 +5110,89 @@ class _TasksPanelState extends State<TasksPanel> {
4424
5110
 
4425
5111
  NeoAgentController get controller => widget.controller;
4426
5112
 
5113
+ Color _taskRunStatusColor(String status) {
5114
+ switch (status) {
5115
+ case 'completed':
5116
+ return _success;
5117
+ case 'failed':
5118
+ case 'error':
5119
+ return _danger;
5120
+ case 'running':
5121
+ case 'retrying':
5122
+ return _warning;
5123
+ default:
5124
+ return _textSecondary;
5125
+ }
5126
+ }
5127
+
5128
+ Future<void> _showLastRun(TaskItem task) async {
5129
+ final runId = task.lastRunId.trim();
5130
+ if (runId.isEmpty) return;
5131
+
5132
+ try {
5133
+ final detail = await controller.fetchRunDetail(runId, force: true);
5134
+ if (!mounted) return;
5135
+ await showDialog<void>(
5136
+ context: context,
5137
+ builder: (dialogContext) => AlertDialog(
5138
+ title: Text(detail.run.title),
5139
+ content: ConstrainedBox(
5140
+ constraints: const BoxConstraints(maxWidth: 620),
5141
+ child: SingleChildScrollView(
5142
+ child: Column(
5143
+ mainAxisSize: MainAxisSize.min,
5144
+ crossAxisAlignment: CrossAxisAlignment.start,
5145
+ children: <Widget>[
5146
+ Wrap(
5147
+ spacing: 8,
5148
+ runSpacing: 8,
5149
+ children: <Widget>[
5150
+ _StatusPill(
5151
+ label: detail.run.statusLabel,
5152
+ color: detail.run.statusColor,
5153
+ ),
5154
+ _StatusPill(
5155
+ label: '${detail.steps.length} steps',
5156
+ color: _textSecondary,
5157
+ ),
5158
+ ],
5159
+ ),
5160
+ if (detail.run.error.trim().isNotEmpty) ...<Widget>[
5161
+ const SizedBox(height: 16),
5162
+ Text(detail.run.error, style: TextStyle(color: _danger)),
5163
+ ],
5164
+ if (detail.response.trim().isNotEmpty) ...<Widget>[
5165
+ const SizedBox(height: 16),
5166
+ SelectableText(detail.response),
5167
+ ],
5168
+ if (detail.response.trim().isEmpty &&
5169
+ detail.run.error.trim().isEmpty) ...<Widget>[
5170
+ const SizedBox(height: 16),
5171
+ Text(
5172
+ 'This run did not produce a user-facing response.',
5173
+ style: TextStyle(color: _textSecondary),
5174
+ ),
5175
+ ],
5176
+ ],
5177
+ ),
5178
+ ),
5179
+ ),
5180
+ actions: <Widget>[
5181
+ TextButton(
5182
+ onPressed: () => Navigator.of(dialogContext).pop(),
5183
+ child: const Text('Close'),
5184
+ ),
5185
+ ],
5186
+ ),
5187
+ );
5188
+ } catch (error) {
5189
+ if (!mounted) return;
5190
+ ScaffoldMessenger.of(context).showSnackBar(
5191
+ SnackBar(content: Text(controller.friendlyErrorMessage(error))),
5192
+ );
5193
+ }
5194
+ }
5195
+
4427
5196
  @override
4428
5197
  void didUpdateWidget(covariant TasksPanel oldWidget) {
4429
5198
  super.didUpdateWidget(oldWidget);
@@ -4572,6 +5341,13 @@ class _TasksPanelState extends State<TasksPanel> {
4572
5341
  label: task.enabled ? 'Active' : 'Paused',
4573
5342
  color: task.enabled ? _success : _textSecondary,
4574
5343
  ),
5344
+ if (task.hasLastRunStatus) ...<Widget>[
5345
+ const SizedBox(width: 8),
5346
+ _StatusPill(
5347
+ label: 'Last: ${task.lastRunStatusLabel}',
5348
+ color: _taskRunStatusColor(task.lastRunStatus),
5349
+ ),
5350
+ ],
4575
5351
  ],
4576
5352
  ),
4577
5353
  const SizedBox(height: 10),
@@ -4579,7 +5355,7 @@ class _TasksPanelState extends State<TasksPanel> {
4579
5355
  task.scheduleLabel,
4580
5356
  style: TextStyle(
4581
5357
  color: _textSecondary,
4582
- fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
5358
+ fontFamily: GoogleFonts.geistMono().fontFamily,
4583
5359
  ),
4584
5360
  ),
4585
5361
  if (task.hasModelOverride) ...<Widget>[
@@ -4603,6 +5379,11 @@ class _TasksPanelState extends State<TasksPanel> {
4603
5379
  style: TextStyle(color: _textSecondary),
4604
5380
  ),
4605
5381
  ],
5382
+ if (task.lastRunFailed &&
5383
+ task.lastRunError.trim().isNotEmpty) ...<Widget>[
5384
+ const SizedBox(height: 8),
5385
+ Text(task.lastRunError, style: TextStyle(color: _danger)),
5386
+ ],
4606
5387
  const SizedBox(height: 14),
4607
5388
  Wrap(
4608
5389
  spacing: 10,
@@ -4622,6 +5403,11 @@ class _TasksPanelState extends State<TasksPanel> {
4622
5403
  : () => controller.runTaskNow(task.id),
4623
5404
  child: Text(_manualRunButtonLabel('Run Now', remaining)),
4624
5405
  ),
5406
+ if (task.lastRunId.trim().isNotEmpty)
5407
+ OutlinedButton(
5408
+ onPressed: () => _showLastRun(task),
5409
+ child: const Text('View last run'),
5410
+ ),
4625
5411
  OutlinedButton(
4626
5412
  onPressed: () => _confirmDelete(
4627
5413
  context,
@@ -4674,6 +5460,13 @@ class _TasksPanelState extends State<TasksPanel> {
4674
5460
  label: task.enabled ? 'Active' : 'Paused',
4675
5461
  color: task.enabled ? _success : _textSecondary,
4676
5462
  ),
5463
+ if (task.hasLastRunStatus) ...<Widget>[
5464
+ const SizedBox(width: 8),
5465
+ _StatusPill(
5466
+ label: 'Last: ${task.lastRunStatusLabel}',
5467
+ color: _taskRunStatusColor(task.lastRunStatus),
5468
+ ),
5469
+ ],
4677
5470
  ],
4678
5471
  ),
4679
5472
  const SizedBox(height: 10),
@@ -4681,7 +5474,7 @@ class _TasksPanelState extends State<TasksPanel> {
4681
5474
  task.scheduleLabel,
4682
5475
  style: TextStyle(
4683
5476
  color: _textSecondary,
4684
- fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
5477
+ fontFamily: GoogleFonts.geistMono().fontFamily,
4685
5478
  ),
4686
5479
  ),
4687
5480
  const SizedBox(height: 8),
@@ -4708,6 +5501,11 @@ class _TasksPanelState extends State<TasksPanel> {
4708
5501
  style: TextStyle(color: _textSecondary),
4709
5502
  ),
4710
5503
  ],
5504
+ if (task.lastRunFailed &&
5505
+ task.lastRunError.trim().isNotEmpty) ...<Widget>[
5506
+ const SizedBox(height: 8),
5507
+ Text(task.lastRunError, style: TextStyle(color: _danger)),
5508
+ ],
4711
5509
  const SizedBox(height: 14),
4712
5510
  Wrap(
4713
5511
  spacing: 10,
@@ -4737,6 +5535,11 @@ class _TasksPanelState extends State<TasksPanel> {
4737
5535
  _manualRunButtonLabel('Refresh Now', remaining),
4738
5536
  ),
4739
5537
  ),
5538
+ if (task.lastRunId.trim().isNotEmpty)
5539
+ OutlinedButton(
5540
+ onPressed: () => _showLastRun(task),
5541
+ child: const Text('View last run'),
5542
+ ),
4740
5543
  OutlinedButton(
4741
5544
  onPressed: linkedWidget == null
4742
5545
  ? null