settings = $settings; parent::__construct( 'search-admin-pages', 'Searches the admin panel sitemap to find pages, settings, or navigation items that match the user\'s intent. Use this tool whenever a user wants to DO something or GO somewhere in the admin panel: "where is the events page", "how can I change the currency", "where do I configure payments", "how to manage attendees", "how do I enable Stripe", "where is the speaker settings", etc. Use it for any question with action verbs (create, add, edit, change, configure, manage, enable, disable, delete, set up, find) directed at plugin features or settings. Read the returned results and provide an aisentic-button link to the best matching page.' ); } /** * Define tool properties * * @return array */ protected function properties(): array { return [ new ToolProperty( name: 'query', type: PropertyType::STRING, description: 'Concise phrase describing what the user wants to do or find (e.g. "enable stripe", "create event", "configure zoom")', required: true ), new ToolProperty( name: 'plugin', type: PropertyType::STRING, description: 'The plugin or theme the search is about. Use a plugin slug (eventin, wpcafe, timetics, booktics, pointics, aisentic), "wordpress-core" for built-in WordPress admin pages (posts, pages, media, comments, users, appearance, plugins, settings), or a Themewinter theme key of the form "-theme" (e.g. "cafesio-theme"). The system prompt states the current plugin or theme key — pass that.', required: false ), ]; } /** * All accepted scope values: base plugins plus theme documentation keys. * * @return string[] */ private function allowed_plugins(): array { return array_merge( Screen_Id_To_Plugin_Mapper::get_integration_plugins(), [ Screen_Id_To_Plugin_Mapper::WORDPRESS_CORE_KEY ], Active_Theme_Resolver::get_all_theme_keys() ); } /** * Execute the tool * * @param string $query Search query. * @param string|null $plugin Plugin to search in. * @return string */ public function __invoke( string $query, ?string $plugin = null ): string { $endpoint = $this->settings->get( 'llmProviders.aisentic.endpoint', '' ); $api_key = $this->settings->get( 'llmProviders.aisentic.apiKey', '' ); if ( empty( $endpoint ) ) { $endpoint = AISENTIC_AI_PROVIDER; } if ( empty( $endpoint ) || empty( $api_key ) ) { Log::warning( 'SitemapSearchTool: Aisentic AI Provider endpoint or API key not configured' ); return 'Sitemap search is not available on this site because the Aisentic AI Provider is not configured. Do not retry this tool — instead, answer the user\'s question from your general knowledge of WordPress admin pages.'; } // Validate and normalize plugin value $plugins = null; if ( ! empty( $plugin ) ) { $normalized_plugin = strtolower( trim( $plugin ) ); if ( in_array( $normalized_plugin, $this->allowed_plugins(), true ) ) { $plugins = [ $normalized_plugin ]; } } $request_url = rtrim( $endpoint, '/' ) . '/api/v1/sitemap/search'; $request_body = wp_json_encode( [ 'query' => $query, 'top_k' => 2, 'plugins' => $plugins, ] ); $response = wp_remote_post( $request_url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ], 'body' => $request_body, 'timeout' => 10, ] ); if ( is_wp_error( $response ) ) { Log::error( 'SitemapSearchTool: API request failed', [ 'error' => $response->get_error_message() ] ); return wp_json_encode( [ 'error' => $response->get_error_message(), 'matches' => [], 'total' => 0, ], JSON_UNESCAPED_SLASHES ); } $http_code = (int) wp_remote_retrieve_response_code( $response ); if ( $http_code < 200 || $http_code >= 300 ) { Log::error( 'SitemapSearchTool: API returned non-2xx status', [ 'status' => $http_code ] ); return wp_json_encode( [ 'error' => "API returned HTTP {$http_code}", 'matches' => [], 'total' => 0 ], JSON_UNESCAPED_SLASHES ); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { Log::error( 'SitemapSearchTool: Failed to parse API response', [ 'raw' => substr( $body, 0, 200 ) ] ); return wp_json_encode( [ 'error' => 'Invalid JSON response from API', 'matches' => [], 'total' => 0 ], JSON_UNESCAPED_SLASHES ); } $matches = $data['matches'] ?? []; // Convert relative URLs to absolute admin URLs. $results = array_map( function ( $match ) { return [ 'title' => $match['title'] ?? '', 'url' => admin_url( $this->sanitize_relative_url( $match['relative_url'] ?? '' ) ), 'description' => $match['description'] ?? '', 'plugin' => $match['plugin'] ?? '', 'score' => $match['score'] ?? 0, ]; }, $matches ); $min_score = (float) apply_filters( 'aisentic_sitemap_min_score', self::MIN_SCORE ); $found = array_values( array_filter( $results, static fn( $result ) => (float) $result['score'] >= $min_score ) ); if ( empty( $found ) ) { return wp_json_encode( [ 'matches' => [], 'total' => 0, 'note' => "Nothing in this site's admin matches \"{$query}\". Say plainly that you could not find that setting here, and that the plugin may not have it. Do not open a page that only sounds similar. You may offer the plugin's main settings page as a place to look.", ], JSON_UNESCAPED_SLASHES ); } return wp_json_encode( [ 'matches' => $found, 'total' => count( $found ) ], JSON_UNESCAPED_SLASHES ); } /** * Sanitize a relative URL from the API before passing to admin_url(). * * @param string $relative_url Relative URL to sanitize. * @return string Safe relative URL, or empty string on failure. */ private function sanitize_relative_url( string $relative_url ): string { // Reject absolute URLs, protocol-relative, or path traversal. if ( preg_match( '#^https?://#i', $relative_url ) || 0 === strpos( $relative_url, '//' ) || false !== strpos( $relative_url, '..' ) ) { return ''; } // Strip leading slashes — admin_url() will prepend the correct base. $relative_url = ltrim( $relative_url, '/' ); // Allow only safe characters. if ( ! preg_match( '#^[a-zA-Z0-9/_\-=&?%.+\#]*$#', $relative_url ) ) { return ''; } return $relative_url; } }