Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
wp-content
/
plugins
/
elementor
/
modules
/
agents
:
module.php
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
<?php namespace Elementor\Modules\Agents; use Elementor\Core\Base\Module as BaseModule; use Elementor\Core\Common\Modules\Ajax\Module as Ajax; use Elementor\Core\Kits\Documents\Kit; use Elementor\Core\Utils\Exceptions; use Elementor\Core\Utils\Hints; use Elementor\Modules\Agents\AdminMenuItems\Editor_One_Agent_Access_Menu; use Elementor\Modules\Agents\AdminMenuItems\Editor_One_Agents_Ready_Menu; use Elementor\Modules\Agents\Classes\Agents_Sidebar_Config; use Elementor\Modules\Agents\Classes\Content_Items_Ajax; use Elementor\Modules\Agents\Classes\Feature_Component; use Elementor\Modules\Agents\Classes\Feature_Registry; use Elementor\Modules\Agents\Classes\Llms_Content_Ajax; use Elementor\Modules\Agents\Classes\Llms_Revert_Ajax; use Elementor\Modules\Agents\Classes\Llms_Takeover_Ajax; use Elementor\Modules\Agents\Classes\Markdown_Content_Ajax; use Elementor\Modules\Agents\Classes\Module_Availability; use Elementor\Modules\Agents\Classes\Seo_Llms_Txt_Detector; use Elementor\Modules\Agents\Classes\Markdown_Content_Catalog; use Elementor\Modules\Agents\Classes\Markdown_Preview_Ajax; use Elementor\Modules\Agents\Classes\Markdown_Revert_Ajax; use Elementor\Modules\Agents\Classes\Markdown_Search_Ajax; use Elementor\Modules\Agents\Classes\Post_Exclusions; use Elementor\Modules\Agents\Classes\Request_Path; use Elementor\Modules\Agents\Classes\Scanner_Client; use Elementor\Modules\Agents\Classes\Scanner_Report_Store; use Elementor\Modules\Agents\Classes\Scanner_Orchestrator; use Elementor\Modules\Agents\Classes\Scanner_Rest_Api; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Agent_Skills; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Api_Catalog; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Ard_Catalog; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Ard_Manifest; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Auth_Md; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Mcp_Server_Card; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Oauth_Authorization_Server; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Oauth_Protected_Resource; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Webmcp_Manifest; use Elementor\Modules\Agents\Components\Discovery\Well_Known\Well_Known_Router; use Elementor\Modules\Agents\Components\Discovery\Link_Headers; use Elementor\Modules\Agents\Components\Discovery\Mcp_Discovery; use Elementor\Modules\Agents\Components\Discovery\Openapi_Spec; use Elementor\Modules\Agents\Components\Readability\Markdown_Endpoint; use Elementor\Modules\Agents\Components\Readability\Markdown_Not_Found; use Elementor\Modules\Agents\Components\Webmcp\Business_Info_Extractor; use Elementor\Modules\Agents\Components\Webmcp\Business_Schema_Parser; use Elementor\Modules\Agents\Components\Webmcp\Tools\Get_Site_Structure; use Elementor\Modules\Agents\Components\Webmcp\Tools\Query_Business_Info; use Elementor\Modules\Agents\Components\Webmcp\Tools\Read_Page_Content; use Elementor\Modules\Agents\Components\Webmcp\Tools\Search_Site_Content; use Elementor\Modules\Agents\Components\Webmcp\Webmcp_Browser_Tools; use Elementor\Modules\EditorOne\Classes\Menu_Data_Provider; use Elementor\Plugin; use Elementor\Utils; if ( ! defined( 'ABSPATH' ) ) { exit; } class Module extends BaseModule { /** Default HTTP max-age for the served response (5 minutes). */ const DEFAULT_CACHE_MAX_AGE = 300; const PAGE_ID = 'elementor-agents-ready'; const AGENT_ACCESS_PAGE_ID = 'elementor-agent-access'; const MOUNT_ID = 'e-agents-ready'; const SCANNER_MOUNT_ID = 'e-agents-ready-scanner'; const SCRIPT_HANDLE = 'e-agents-ready-app'; const CONFIG_OBJECT_NAME = 'elementorAgentsReadyConfig'; const AJAX_ACTIVATE_ACTION = 'agents_ready_activate'; const EDITOR_ONE_MENU_REGISTER_PRIORITY = 12; const LLMS_FILENAME = 'llms.txt'; /** * Option name that records whether, at the time the feature was first * activated, a physical llms.txt already existed in the web root. * Value: 'keep' | 'replace' | '' (not decided yet). */ const OPTION_EXISTING_FILE_DECISION = 'elementor_agents_llms_existing_file_decision'; /** * Option name that stores per-file overrides the admin can edit: * 'intro' and 'optional' keys. */ const OPTION_OVERRIDES = 'elementor_agents_llms_overrides'; private Feature_Registry $feature_registry; private Llms_Cache $cache; private Content_Generator $generator; private Robots_Txt_Handler $robots_handler; private Well_Known_Router $well_known_router; private Agent_Ready_Settings $settings; private Llms_Manual_Content $manual_content; private Bot_Catalog $bot_catalog; private Markdown_Endpoint $markdown_endpoint; private Markdown_Manual_Content $markdown_manual_content; private Scanner_Report_Store $scanner_store; private Scanner_Orchestrator $scanner_orchestrator; private Webmcp_Browser_Tools $webmcp_browser_tools; private Seo_Llms_Txt_Detector $seo_detector; private Module_Availability $module_availability; public function get_name() { return 'agents'; } public function __construct() { parent::__construct(); $sanitizer = new Prompt_Injection_Sanitizer(); $this->generator = new Content_Generator( $sanitizer ); $this->cache = new Llms_Cache(); $this->bot_catalog = new Bot_Catalog(); $this->feature_registry = new Feature_Registry(); $this->settings = new Agent_Ready_Settings( $this->generator, $this->bot_catalog ); $this->robots_handler = new Robots_Txt_Handler( $this->settings ); $this->manual_content = new Llms_Manual_Content( $this->generator ); $this->scanner_store = new Scanner_Report_Store(); $this->scanner_orchestrator = new Scanner_Orchestrator( new Scanner_Client(), $this->scanner_store ); $this->seo_detector = new Seo_Llms_Txt_Detector(); $this->module_availability = new Module_Availability( $this->seo_detector, $this->robots_handler ); $this->settings->register(); ( new Scanner_Rest_Api( $this->scanner_orchestrator ) )->register_hooks(); $watched_options = array_merge( [ Agent_Ready_Settings::OPTION, Llms_Manual_Content::OPTION, self::OPTION_EXISTING_FILE_DECISION ], Mcp_Discovery::get_settings_option_names() ); foreach ( $watched_options as $option ) { add_action( "add_option_{$option}", [ $this, 'on_settings_add' ], 10, 2 ); add_action( "update_option_{$option}", [ $this, 'on_settings_update' ], 10, 3 ); } add_action( 'add_option_wpseo', [ $this, 'on_seo_plugin_option_change' ] ); add_action( 'update_option_wpseo', [ $this, 'on_seo_plugin_option_change' ] ); add_action( 'add_option_rank_math_modules', [ $this, 'on_seo_plugin_option_change' ] ); add_action( 'update_option_rank_math_modules', [ $this, 'on_seo_plugin_option_change' ] ); add_action( 'add_option_aioseo_options', [ $this, 'on_seo_plugin_option_change' ] ); add_action( 'update_option_aioseo_options', [ $this, 'on_seo_plugin_option_change' ] ); if ( $this->settings->is_module_active( Agent_Ready_Settings::MODULE_LLMS_TXT ) ) { add_action( 'template_redirect', [ $this, 'maybe_serve_llms_txt' ], 1 ); add_action( 'template_redirect', [ $this, 'maybe_serve_llms_full_txt' ], 1 ); } if ( $this->settings->has_any_active( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ] ) ) { add_action( 'save_post', [ $this, 'on_post_change' ], 10, 2 ); add_action( 'trashed_post', [ $this, 'on_post_state_change' ] ); add_action( 'untrashed_post', [ $this, 'on_post_state_change' ] ); add_action( 'before_delete_post', [ $this, 'on_post_state_change' ] ); add_action( 'elementor/document/after_save', [ $this, 'on_elementor_document_save' ] ); add_action( 'switch_theme', [ $this, 'on_global_change' ] ); add_action( 'activated_plugin', [ $this, 'on_global_change' ] ); add_action( 'deactivated_plugin', [ $this, 'on_global_change' ] ); add_action( 'elementor/core/files/clear_cache', [ $this, 'on_global_change' ] ); } add_action( 'elementor/ajax/register_actions', [ $this, 'register_ajax_actions' ] ); add_action( 'switch_blog', [ __CLASS__, 'reset_physical_file_cache' ] ); add_action( 'switch_blog', [ __CLASS__, 'reset_seo_detector_cache' ] ); if ( $this->settings->is_module_active( Agent_Ready_Settings::MODULE_BOT_ACCESS_CONTROL ) ) { $this->robots_handler->register(); } $this->well_known_router = new Well_Known_Router( $this->settings ); $this->add_component( 'well_known_router', $this->well_known_router ); if ( $this->settings->is_module_active( Agent_Ready_Settings::MODULE_AGENT_DISCOVERY ) ) { $this->well_known_router->init(); } $this->register_well_known_endpoint( new Oauth_Authorization_Server() ); $this->register_well_known_endpoint( new Webmcp_Manifest() ); $this->register_well_known_endpoint( new Oauth_Protected_Resource() ); $this->register_well_known_endpoint( new Auth_Md() ); $this->register_well_known_endpoint( new Api_Catalog() ); $this->register_well_known_endpoint( new Agent_Skills() ); $this->register_well_known_endpoint( new Ard_Manifest( $this ) ); $this->register_well_known_endpoint( new Ard_Catalog( $this ) ); $this->register_well_known_endpoint( new Mcp_Server_Card() ); $this->markdown_manual_content = new Markdown_Manual_Content( $this->generator ); $this->markdown_endpoint = new Markdown_Endpoint( $this->settings, $this->markdown_manual_content ); $this->register_component( new Mcp_Discovery() ); $this->register_component( new Openapi_Spec( $this->settings ) ); $this->register_component( new Link_Headers( $this->markdown_endpoint, $this->settings, $this ) ); $this->register_component( $this->markdown_endpoint ); $this->register_component( new Markdown_Not_Found( $this->settings, $this->markdown_endpoint, $this ) ); $this->webmcp_browser_tools = $this->create_webmcp_browser_tools( $sanitizer ); $this->register_component( $this->webmcp_browser_tools ); add_action( 'admin_init', [ $this, 'maybe_detect_existing_file' ] ); ( new Agents_Sidebar_Config() )->register(); add_action( 'elementor/editor-one/menu/register', [ $this, 'register_editor_one_menu' ], self::EDITOR_ONE_MENU_REGISTER_PRIORITY ); add_action( 'elementor/editor-one/menu/after_register_hidden_submenus', [ $this, 'enqueue_assets_for_editor_one_menu' ] ); } // ------------------------------------------------------------------------- // Editor One menu // ------------------------------------------------------------------------- /** * Register the Readiness Score and Agent Access items in Editor One. * * @param Menu_Data_Provider $menu_data_provider Editor One menu registry. */ public function register_editor_one_menu( Menu_Data_Provider $menu_data_provider ): void { $menu_data_provider->register_menu( new Editor_One_Agents_Ready_Menu() ); $menu_data_provider->register_menu( new Editor_One_Agent_Access_Menu() ); } public function enqueue_assets_for_editor_one_menu( array $hooks ): void { foreach ( [ self::PAGE_ID, self::AGENT_ACCESS_PAGE_ID ] as $page_id ) { if ( ! empty( $hooks[ $page_id ] ) ) { add_action( "admin_print_scripts-{$hooks[ $page_id ]}", [ $this, 'enqueue_assets' ] ); } } } public function enqueue_assets(): void { wp_enqueue_script( self::SCRIPT_HANDLE, $this->get_js_assets_url( 'agents-ready' ), [ 'react', 'react-dom', 'elementor-common', 'elementor-v2-ui', 'wp-api-fetch' ], ELEMENTOR_VERSION, true ); wp_localize_script( self::SCRIPT_HANDLE, self::CONFIG_OBJECT_NAME, $this->get_app_config() ); wp_set_script_translations( self::SCRIPT_HANDLE, 'elementor' ); } public function register_ajax_actions( Ajax $ajax ): void { $ajax->register_ajax_action( self::AJAX_ACTIVATE_ACTION, [ $this, 'ajax_activate' ] ); $llms_content_ajax = new Llms_Content_Ajax( $this->settings, $this->manual_content ); $ajax->register_ajax_action( Llms_Content_Ajax::ACTION, [ $llms_content_ajax, 'handle' ] ); $llms_revert_ajax = new Llms_Revert_Ajax( $this->settings, $this->manual_content ); $ajax->register_ajax_action( Llms_Revert_Ajax::ACTION, [ $llms_revert_ajax, 'handle' ] ); $llms_takeover_ajax = new Llms_Takeover_Ajax( $this->settings, $this->manual_content ); $ajax->register_ajax_action( Llms_Takeover_Ajax::ACTION, [ $llms_takeover_ajax, 'handle' ] ); $markdown_preview_ajax = new Markdown_Preview_Ajax( $this->markdown_endpoint ); $ajax->register_ajax_action( Markdown_Preview_Ajax::ACTION, [ $markdown_preview_ajax, 'handle' ] ); $markdown_content_ajax = new Markdown_Content_Ajax( $this->markdown_endpoint, $this->markdown_manual_content ); $ajax->register_ajax_action( Markdown_Content_Ajax::ACTION, [ $markdown_content_ajax, 'handle' ] ); $markdown_revert_ajax = new Markdown_Revert_Ajax( $this->markdown_manual_content ); $ajax->register_ajax_action( Markdown_Revert_Ajax::ACTION, [ $markdown_revert_ajax, 'handle' ] ); $markdown_search_ajax = new Markdown_Search_Ajax( $this->settings, new Markdown_Content_Catalog( $this->markdown_manual_content ) ); $ajax->register_ajax_action( Markdown_Search_Ajax::ACTION, [ $markdown_search_ajax, 'handle' ] ); $content_items_ajax = new Content_Items_Ajax( $this->settings, new Markdown_Content_Catalog( $this->markdown_manual_content ) ); $ajax->register_ajax_action( Content_Items_Ajax::ACTION, [ $content_items_ajax, 'handle' ] ); } public function ajax_activate(): void { if ( ! current_user_can( 'manage_options' ) ) { throw new \Exception( esc_html__( 'Permission denied.', 'elementor' ), Exceptions::FORBIDDEN ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } $this->activate_agent_ready(); } /** * @return array{ * activated: bool, * enabledModules: string[], * skippedModules: array<string, array{code: string, message: string}> * } */ public function activate_agent_ready(): array { $unavailable_reasons = $this->module_availability->get_unavailable_reasons(); $skip_modules = array_keys( $unavailable_reasons ); $this->settings->activate( $skip_modules ); return $this->build_activation_result( $unavailable_reasons ); } public function get_scanner_orchestrator(): Scanner_Orchestrator { return $this->scanner_orchestrator; } /** * @param array<string, array{code: string, message: string}> $unavailable_reasons */ private function build_activation_result( array $unavailable_reasons ): array { $enabled_modules = []; foreach ( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_MARKDOWN_CONTENT, Agent_Ready_Settings::MODULE_BOT_ACCESS_CONTROL, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ] as $module ) { if ( $this->module_is_enabled( $module ) ) { $enabled_modules[] = $module; } } return [ 'activated' => $this->settings->is_activated(), 'enabledModules' => $enabled_modules, 'skippedModules' => $unavailable_reasons, ]; } private function module_is_enabled( string $module ): bool { switch ( $module ) { case Agent_Ready_Settings::MODULE_LLMS_TXT: return $this->settings->is_llms_enabled(); case Agent_Ready_Settings::MODULE_MARKDOWN_CONTENT: return $this->settings->is_markdown_enabled(); case Agent_Ready_Settings::MODULE_BOT_ACCESS_CONTROL: return $this->settings->is_bot_access_enabled(); case Agent_Ready_Settings::MODULE_AGENT_DISCOVERY: return $this->settings->is_agent_discovery_enabled(); case Agent_Ready_Settings::MODULE_BROWSER_TOOLS: return $this->settings->is_browser_tools_enabled(); default: return false; } } // ------------------------------------------------------------------------- // HTTP request handling // ------------------------------------------------------------------------- /** * Per-blog cache for physical file existence checks. * Keyed by blog ID to support multisite. * * @var array<int, bool> */ private static array $physical_file_cache = []; /** * Reset the physical file cache. Used by tests and on blog switch. */ public static function reset_physical_file_cache(): void { self::$physical_file_cache = []; } /** * Reset the SEO detector cache. Used by tests and on blog switch. */ public static function reset_seo_detector_cache(): void { Seo_Llms_Txt_Detector::reset_cache(); } /** * Check whether llms.txt should be advertised in discovery mechanisms. * * Returns true only when Elementor's llms.txt is actually served. * Returns false when: * - The feature is not activated * - The feature is disabled * - A physical file exists (even if admin chose 'replace', the web server * serves the static file before WordPress runs) * - An SEO plugin owns llms.txt * * This is the single source of truth for both serving and advertising. * Not memoized to avoid leaking state between tests and across settings changes. */ public function should_advertise_llms_txt(): bool { if ( ! $this->settings->is_activated() ) { return false; } if ( ! $this->settings->is_llms_enabled() ) { return false; } $decision = get_option( self::OPTION_EXISTING_FILE_DECISION, '' ); if ( 'keep' === $decision ) { return false; } if ( self::has_physical_llms_file() ) { return false; } if ( $this->seo_plugin_generates_llms_txt() ) { return false; } return true; } /** * Check whether llms-full.txt should be advertised in discovery mechanisms. * * Llms-full.txt is always served by WordPress (no web server file concern), * but we check if a physical file exists to avoid conflicts. * * @return bool */ public function should_advertise_llms_full_txt(): bool { if ( ! $this->settings->is_activated() ) { return false; } if ( ! $this->settings->is_llms_enabled() ) { return false; } if ( self::has_physical_llms_full_file() ) { return false; } return true; } /** * Check whether a physical llms-full.txt file exists. * * Uses the same per-blog cache as has_physical_llms_file. * * @return bool */ public static function has_physical_llms_full_file(): bool { $blog_id = get_current_blog_id(); $cache_key = 'full_' . $blog_id; if ( isset( self::$physical_file_cache[ $cache_key ] ) ) { return self::$physical_file_cache[ $cache_key ]; } $root_path = self::get_llms_file_root_path(); self::$physical_file_cache[ $cache_key ] = file_exists( $root_path . 'llms-full.txt' ); return self::$physical_file_cache[ $cache_key ]; } /** * Check whether an SEO plugin is generating llms.txt. * * @return bool */ public function seo_plugin_generates_llms_txt(): bool { return null !== $this->seo_detector->get_owner(); } /** * Serve llms.txt when the request path matches. */ public function maybe_serve_llms_txt() { if ( ! $this->settings->is_module_active( Agent_Ready_Settings::MODULE_LLMS_TXT ) ) { return; } if ( ! $this->is_request_for( 'llms.txt' ) ) { return; } if ( ! $this->should_advertise_llms_txt() ) { return; } $manual_content = $this->settings->is_llms_manually_edited() ? $this->manual_content->get_content() : ''; if ( '' !== $manual_content ) { $this->serve_plain_text( $manual_content, $this->manual_content->get_modified_at() ); return; } $this->serve_plain_text( $this->get_generated_llms_txt(), $this->cache->get_modified_time() ); } public function maybe_serve_llms_full_txt() { if ( ! $this->settings->is_module_active( Agent_Ready_Settings::MODULE_LLMS_TXT ) ) { return; } if ( ! $this->is_request_for( 'llms-full.txt' ) ) { return; } if ( ! $this->should_advertise_llms_full_txt() ) { return; } $this->serve_plain_text( $this->get_generated_llms_full_txt(), $this->cache->get_modified_time() ); } // ------------------------------------------------------------------------- // Content retrieval (cache-aware) // ------------------------------------------------------------------------- /** * Return the generated llms.txt string, served from cache when available. */ public function get_generated_llms_txt(): string { $cached = $this->cache->get_llms(); if ( false !== $cached ) { return $cached; } $overrides = $this->get_overrides(); $content = $this->generator->generate_llms_txt( $overrides, $this->settings->get_llms_post_types(), $this->settings->get_llms_excluded_post_ids() ); if ( '' !== $content ) { $this->cache->set_llms( $content ); } return $content; } /** * Return the generated llms-full.txt string, served from cache when available. */ public function get_generated_llms_full_txt(): string { $cached = $this->cache->get_llms_full(); if ( false !== $cached ) { return $cached; } $overrides = $this->get_overrides(); $content = $this->generator->generate_llms_full_txt( $overrides, $this->settings->get_llms_post_types(), $this->settings->get_llms_excluded_post_ids() ); if ( '' !== $content ) { $this->cache->set_llms_full( $content ); } return $content; } /** * Return any human-readable warnings about missing site configuration that * would improve the generated output quality. * * @return string[] */ public function get_missing_requirements(): array { return $this->generator->get_missing_requirements(); } /** * Stub for backward compatibility. * * @deprecated 4.4.0 Use get_generated_llms_txt() instead. * @return string Always returns an empty string. */ public function get_llms_txt_content(): string { Plugin::$instance->modules_manager->get_modules( 'dev-tools' )->deprecation->deprecated_function( __METHOD__, '4.4.0', __CLASS__ . '::get_generated_llms_txt()' ); return ''; } // ------------------------------------------------------------------------- // Cache invalidation // ------------------------------------------------------------------------- /** * Invalidate cache when any post is saved (including auto-drafts on publish). * * We only invalidate for post types that actually appear in the output, * skipping revisions, attachments, etc., for performance. * * @param int $post_id * @param \WP_Post $post */ public function on_post_change( int $post_id, \WP_Post $post ) { if ( ! $this->settings->has_any_active( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ] ) ) { return; } if ( wp_is_post_revision( $post_id ) ) { return; } if ( in_array( $post->post_type, Content_Generator::EXCLUDED_POST_TYPES, true ) ) { return; } if ( $this->settings->is_module_active( Agent_Ready_Settings::MODULE_LLMS_TXT ) ) { $this->generator->clear_post_cache( $post_id ); } $this->invalidate_cache( $post_id ); } /** * Invalidate cache when a post is trashed, untrashed, or permanently deleted. * * @param int $post_id */ public function on_post_state_change( int $post_id ) { if ( ! $this->settings->has_any_active( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ] ) ) { return; } if ( $this->settings->is_module_active( Agent_Ready_Settings::MODULE_LLMS_TXT ) ) { $this->generator->clear_post_cache( $post_id ); } $this->invalidate_cache( $post_id ); } /** * Invalidate cache on Elementor document saves (handles kit saves too). * * @param mixed $document */ public function on_elementor_document_save( $document ) { if ( ! $this->settings->has_any_active( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ] ) ) { return; } if ( ! ( $document instanceof Kit ) ) { return; } $kit_id = $document->get_main_id(); if ( $kit_id && $this->settings->is_module_active( Agent_Ready_Settings::MODULE_LLMS_TXT ) ) { $this->generator->clear_post_cache( $kit_id ); } $this->invalidate_cache( $kit_id ); } /** * Kept for backward compatibility (old hook name used in tests). * * @param mixed $document */ public function maybe_invalidate_llms_txt_cache( $document ) { $this->on_elementor_document_save( $document ); } /** * Full invalidation: clears every post's inline-content meta cache AND the * assembled-output transients. * * Triggered by events that can affect the rendered output of every post at * once — theme switch, plugin (de)activation, Elementor cache clear. */ public function on_global_change(): void { if ( ! $this->settings->has_any_active( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ] ) ) { return; } if ( $this->settings->is_module_active( Agent_Ready_Settings::MODULE_LLMS_TXT ) ) { $this->generator->clear_all_post_caches(); } $this->cache->invalidate(); do_action( 'elementor/agents/llms_txt/cache_invalidated', 0 ); } /** * Invalidate cache when Agent Ready settings are added via add_option. * Handles add_option_{$option} hook (2 args: $option, $value). */ public function on_settings_add( string $option, $value ): void { $this->invalidate_cache( 0 ); if ( isset( $this->well_known_router ) ) { $this->well_known_router->flush_all_caches(); } self::reset_physical_file_cache(); self::reset_seo_detector_cache(); if ( Agent_Ready_Settings::OPTION === $option ) { $this->clear_caches_for_newly_active_modules( null, $value ); } } /** * Invalidate cache when Agent Ready settings are updated via update_option. * Handles update_option_{$option} hook (3 args: $old_value, $value, $option). */ public function on_settings_update( $old_value, $new_value, string $option ): void { $this->invalidate_cache( 0 ); if ( isset( $this->well_known_router ) ) { $this->well_known_router->flush_all_caches(); } self::reset_physical_file_cache(); self::reset_seo_detector_cache(); if ( Agent_Ready_Settings::OPTION === $option ) { $this->clear_caches_for_newly_active_modules( $old_value, $new_value ); } } /** * Flush well-known endpoint caches when SEO plugin options change. */ public function on_seo_plugin_option_change(): void { if ( ! $this->settings->is_module_active( Agent_Ready_Settings::MODULE_AGENT_DISCOVERY ) ) { return; } self::reset_seo_detector_cache(); if ( isset( $this->well_known_router ) ) { $this->well_known_router->flush_all_caches(); } } /** * Clear caches for modules that were just activated or had 'activated' toggled true. */ private function clear_caches_for_newly_active_modules( $old_value, $new_value ): void { $old_settings = is_array( $old_value ) ? $old_value : []; $new_settings = is_array( $new_value ) ? $new_value : []; $old_activated = ! empty( $old_settings[ Agent_Ready_Settings::ACTIVATED_KEY ] ); $new_activated = ! empty( $new_settings[ Agent_Ready_Settings::ACTIVATED_KEY ] ); $modules_to_clear = []; if ( ! $old_activated && $new_activated ) { $modules_to_clear = [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ]; } else { foreach ( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, Agent_Ready_Settings::MODULE_BROWSER_TOOLS, ] as $module ) { $old_enabled = ! empty( $old_settings[ $module ]['enabled'] ); $new_enabled = ! empty( $new_settings[ $module ]['enabled'] ); if ( ! $old_enabled && $new_enabled ) { $modules_to_clear[] = $module; } } } foreach ( $modules_to_clear as $module ) { if ( Agent_Ready_Settings::MODULE_LLMS_TXT === $module ) { $this->generator->clear_all_post_caches(); $this->cache->invalidate(); } elseif ( Agent_Ready_Settings::MODULE_AGENT_DISCOVERY === $module ) { if ( isset( $this->well_known_router ) ) { $this->well_known_router->flush_all_caches(); } } elseif ( Agent_Ready_Settings::MODULE_BROWSER_TOOLS === $module && isset( $this->webmcp_browser_tools ) ) { $this->webmcp_browser_tools->flush_relevant_tools_cache(); $this->webmcp_browser_tools->sync_business_info_with_settings(); } } } // ------------------------------------------------------------------------- // Existing-file detection // ------------------------------------------------------------------------- /** * On the first admin page-load after the feature is activated, check whether * a physical /llms.txt already exists in the WordPress root. If it does, we * store a flag so the admin UI can prompt the user to decide what to do. */ public function maybe_detect_existing_file() { // Already decided — nothing to do. if ( '' !== get_option( self::OPTION_EXISTING_FILE_DECISION, '' ) ) { return; } if ( self::has_physical_llms_file() ) { // Store a transient so the admin notice can surface the conflict. set_transient( 'elementor_agents_llms_existing_file_detected', true, DAY_IN_SECONDS ); } } /** * A real file in the site root is served by the web server before WordPress * runs, so when one exists the module must stay out of the way. * * Uses a derived home path that works on subdirectory installs, since * get_home_path() can fall back to ABSPATH incorrectly on the front end * when WP is in its own directory. * * Result is memoized per-blog to avoid repeated file_exists() calls. */ public static function has_physical_llms_file(): bool { $blog_id = get_current_blog_id(); if ( isset( self::$physical_file_cache[ $blog_id ] ) ) { return self::$physical_file_cache[ $blog_id ]; } $root_path = self::get_llms_file_root_path(); self::$physical_file_cache[ $blog_id ] = file_exists( $root_path . self::LLMS_FILENAME ); return self::$physical_file_cache[ $blog_id ]; } public static function delete_physical_llms_file(): bool { $file_path = self::get_llms_file_root_path() . self::LLMS_FILENAME; wp_delete_file( $file_path ); clearstatcache( true, $file_path ); self::reset_physical_file_cache(); return ! file_exists( $file_path ); } /** * Get the root directory where llms.txt would be served from. * * Derives the path by subtracting the site path from ABSPATH, since * get_home_path() can return ABSPATH on the front end for subdirectory * installs, which is incorrect when WP is in its own directory. * * @return string Path with trailing slash. */ public static function get_llms_file_root_path(): string { $home_url = trailingslashit( home_url() ); $site_url = trailingslashit( site_url() ); // When home and site URLs are the same, WP root = site root. if ( $home_url === $site_url ) { return ABSPATH; } // Calculate the relative path from home to site. $home_path = wp_parse_url( $home_url, PHP_URL_PATH ); $site_path = wp_parse_url( $site_url, PHP_URL_PATH ); if ( ! $home_path || ! $site_path ) { return ABSPATH; } $home_path = trailingslashit( $home_path ); $site_path = trailingslashit( $site_path ); // If site is deeper than home, subtract the difference from ABSPATH. if ( 0 === strpos( $site_path, $home_path ) ) { $relative = substr( $site_path, strlen( $home_path ) ); if ( $relative ) { return substr( ABSPATH, 0, - strlen( $relative ) ); } } return ABSPATH; } /** * Record the admin's decision about the pre-existing /llms.txt file. * * @param string $decision 'keep' | 'replace' */ public function set_existing_file_decision( string $decision ): void { if ( ! in_array( $decision, [ 'keep', 'replace' ], true ) ) { return; } update_option( self::OPTION_EXISTING_FILE_DECISION, $decision, false ); delete_transient( 'elementor_agents_llms_existing_file_detected' ); } /** * Return true if a physical /llms.txt was detected and the admin has not yet * made a decision about it. */ public function has_pending_existing_file_conflict(): bool { return (bool) get_transient( 'elementor_agents_llms_existing_file_detected' ); } // ------------------------------------------------------------------------- // Overrides (user-editable intro / optional section) // ------------------------------------------------------------------------- /** * Return the stored generator overrides (intro + optional section). * * @return array{intro: string, optional: string} */ public function get_overrides(): array { $stored = get_option( self::OPTION_OVERRIDES, [] ); return [ 'intro' => is_string( $stored['intro'] ?? null ) ? $stored['intro'] : '', 'optional' => is_string( $stored['optional'] ?? null ) ? $stored['optional'] : '', ]; } /** * Persist the user-editable overrides and invalidate the cache. * * @param array{intro?: string, optional?: string} $overrides */ public function save_overrides( array $overrides ): void { $current = $this->get_overrides(); if ( isset( $overrides['intro'] ) ) { $current['intro'] = $this->generator->prepare_override_text( $overrides['intro'] ); } if ( isset( $overrides['optional'] ) ) { $current['optional'] = $this->generator->prepare_override_text( $overrides['optional'] ); } update_option( self::OPTION_OVERRIDES, $current, false ); $this->invalidate_cache( 0 ); } // ------------------------------------------------------------------------- // HTTP serving helpers // ------------------------------------------------------------------------- /** * Send a plain-text HTTP response with caching headers, honoring conditional requests. * * @param string $content Plain-text payload. * @param int $last_modified Unix timestamp of the last content change, or 0 when unknown. */ private function serve_plain_text( string $content, int $last_modified ): void { if ( '' === $content ) { return; } $etag = $this->get_etag( $content ); header( 'X-Content-Type-Options: nosniff' ); header( 'Cache-Control: public, max-age=' . $this->get_cache_max_age() ); header( 'ETag: ' . $etag ); if ( $last_modified ) { header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT' ); } if ( $this->is_client_cache_fresh( $etag, $last_modified ) ) { status_header( 304 ); exit; } status_header( 200 ); header( 'Content-Type: text/plain; charset=utf-8' ); Utils::print_unescaped_internal_string( $content ); exit; } private function invalidate_cache( int $post_id = 0 ): void { $this->cache->invalidate(); if ( ! $this->settings->has_any_active( [ Agent_Ready_Settings::MODULE_LLMS_TXT, Agent_Ready_Settings::MODULE_AGENT_DISCOVERY, ] ) ) { return; } /** * Fires when the cached /llms.txt response has been purged so external * page-caches and CDNs can revalidate. * * @param int $post_id The post ID that triggered the invalidation (0 if unknown). */ do_action( 'elementor/agents/llms_txt/cache_invalidated', $post_id ); } private function get_app_config(): array { $this->settings->ensure_option_exists(); $unavailable_reasons = $this->module_availability->get_unavailable_reasons(); return [ 'isActivated' => $this->settings->is_activated(), 'readinessScoreUrl' => admin_url( 'admin.php?page=' . self::PAGE_ID ), 'llms' => $this->get_llms_state( $unavailable_reasons ), 'markdown' => $this->get_markdown_state(), 'botAccess' => $this->get_bot_access_state( $unavailable_reasons ), 'agentDiscovery' => $this->get_agent_discovery_state(), 'scanner' => $this->get_scanner_state(), 'browserTools' => $this->get_browser_tools_state( $unavailable_reasons ), ]; } private function get_scanner_state(): array { $retry_at = $this->scanner_store->get_retry_at(); return [ 'siteDomain' => wp_parse_url( home_url(), PHP_URL_HOST ), 'agentAccessUrl' => admin_url( 'admin.php?page=' . self::AGENT_ACCESS_PAGE_ID ), 'seoPluginSearchUrl' => admin_url( 'plugin-install.php?s=SEO&tab=search&type=term' ), 'angiePluginSearchUrl' => admin_url( 'plugin-install.php?s=angie&tab=search&type=term' ), 'angieAppUrl' => admin_url( 'admin.php?page=angie-app' ), 'angieInstallHeroUrl' => plugins_url( 'assets/images/angie-install-hero.png', __FILE__ ), 'isAngieActive' => Hints::is_plugin_active( 'angie' ), 'isAngieInstalled' => Hints::is_plugin_installed( 'angie' ), 'lastReport' => $this->scanner_store->get_last_report(), 'retryAt' => null === $retry_at ? null : gmdate( 'c', $retry_at ), ]; } private function get_browser_tools_state( array $unavailable_reasons ): array { return [ 'enabled' => $this->settings->is_browser_tools_enabled(), 'isAvailable' => Webmcp_Browser_Tools::is_available(), 'unavailableReason' => $unavailable_reasons[ Agent_Ready_Settings::MODULE_BROWSER_TOOLS ] ?? null, 'tools' => $this->webmcp_browser_tools->get_admin_tools(), 'businessDetails' => $this->settings->get_business_details(), 'maxBusinessDetailsLength' => Agent_Ready_Settings::MAX_BUSINESS_DETAILS_LENGTH, ]; } private function create_webmcp_browser_tools( Prompt_Injection_Sanitizer $sanitizer ): Webmcp_Browser_Tools { $extractor = new Business_Info_Extractor( new Business_Schema_Parser( $sanitizer ) ); return new Webmcp_Browser_Tools( $this->settings, $extractor, [ new Search_Site_Content( $this->settings, new Markdown_Content_Catalog( $this->markdown_manual_content ), $this->generator, $sanitizer ), new Get_Site_Structure( $this->generator, $sanitizer ), new Read_Page_Content( $this->settings, $this->markdown_endpoint, $sanitizer ), new Query_Business_Info( $this->settings, $extractor, $sanitizer ), ] ); } private function get_agent_discovery_state(): array { $files = []; foreach ( array_keys( $this->well_known_router->get_applicable_endpoints() ) as $slug ) { $files[] = [ 'slug' => $slug, 'url' => home_url( Well_Known_Router::WELL_KNOWN_PREFIX . $slug ), ]; } return [ 'enabled' => $this->settings->is_agent_discovery_enabled(), 'files' => $files, ]; } private function get_bot_access_state( array $unavailable_reasons ): array { return [ 'enabled' => $this->settings->is_bot_access_enabled(), 'hasPhysicalFile' => $this->robots_handler->has_physical_robots_txt(), 'unavailableReason' => $unavailable_reasons[ Agent_Ready_Settings::MODULE_BOT_ACCESS_CONTROL ] ?? null, 'bots' => $this->get_managed_bot_rows(), 'catalog' => $this->get_bot_catalog_rows(), ]; } private function get_managed_bot_rows(): array { $rows = []; foreach ( $this->settings->get_managed_bots() as $token => $permissions ) { $rows[] = [ 'token' => $token, 'search' => $permissions[ Agent_Ready_Settings::BOT_PERMISSION_SEARCH ], 'aiInput' => $permissions[ Agent_Ready_Settings::BOT_PERMISSION_AI_INPUT ], 'aiTrain' => $permissions[ Agent_Ready_Settings::BOT_PERMISSION_AI_TRAIN ], ]; } return $rows; } private function get_bot_catalog_rows(): array { return array_map( fn( array $bot ) => [ 'token' => $bot['token'], 'name' => $bot['name'], 'vendor' => $bot['vendor'], 'logoUrl' => $this->bot_catalog->get_logo_url( $bot['logo'] ), ], $this->bot_catalog->get_bots() ); } private function get_llms_state( array $unavailable_reasons ): array { return [ 'enabled' => $this->settings->is_llms_enabled(), 'isManuallyEdited' => $this->settings->is_llms_manually_edited(), 'hasPhysicalFile' => self::has_physical_llms_file(), 'unavailableReason' => $unavailable_reasons[ Agent_Ready_Settings::MODULE_LLMS_TXT ] ?? null, 'fileUrl' => home_url( '/' . self::LLMS_FILENAME ), 'postTypes' => $this->get_post_type_rows( $this->settings->get_llms_post_types(), $this->settings->get_llms_excluded_post_ids() ), 'excludedPostIds' => $this->settings->get_llms_excluded_post_ids(), ]; } private function get_markdown_state(): array { return [ 'enabled' => $this->settings->is_markdown_enabled(), 'postTypes' => $this->get_post_type_rows( $this->settings->get_markdown_post_types(), $this->settings->get_markdown_excluded_post_ids() ), 'excludedPostIds' => $this->settings->get_markdown_excluded_post_ids(), ]; } /** * @param string[] $included_post_types * @param int[] $excluded_post_ids */ private function get_post_type_rows( array $included_post_types, array $excluded_post_ids = [] ): array { $available_post_types = $this->generator->get_available_post_types(); $excluded_counts = Post_Exclusions::count_published_by_post_type( $excluded_post_ids, array_keys( $available_post_types ) ); $post_types = []; foreach ( $available_post_types as $name => $label ) { $count = (int) ( wp_count_posts( $name )->publish ?? 0 ); if ( $count <= 0 ) { continue; } $post_types[] = [ 'name' => $name, 'label' => $label, 'count' => $count, 'included' => in_array( $name, $included_post_types, true ), 'excludedCount' => $excluded_counts[ $name ] ?? 0, ]; } return $post_types; } private function get_cache_max_age(): int { /** * Filters how long (in seconds) clients and shared caches may reuse * the served llms files without revalidating. * * @param int $max_age Cache lifetime in seconds. */ $max_age = (int) apply_filters( 'elementor/agents/llms_txt/cache_max_age', self::DEFAULT_CACHE_MAX_AGE ); return max( 0, $max_age ); } private function get_etag( string $content ): string { return '"' . md5( $content ) . '"'; } private function is_client_cache_fresh( string $etag, int $last_modified ): bool { $if_none_match = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) ) : ''; // An ETag mismatch must win over If-Modified-Since (1-second resolution). if ( '' !== $if_none_match ) { return $this->etag_matches( $if_none_match, $etag ); } $if_modified_since = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : ''; if ( ! $last_modified || '' === $if_modified_since ) { return false; } $since = strtotime( $if_modified_since ); return false !== $since && $since >= $last_modified; } private function etag_matches( string $header, string $etag ): bool { foreach ( explode( ',', $header ) as $candidate ) { $candidate = trim( $candidate ); if ( '*' === $candidate ) { return true; } if ( 0 === strpos( $candidate, 'W/' ) ) { $candidate = substr( $candidate, 2 ); } if ( $candidate === $etag ) { return true; } } return false; } /** * Check whether the current request URI matches a given filename. * * Handles subdirectory WordPress installs transparently. * * @param string $filename e.g. 'llms.txt' or 'llms-full.txt' */ private function is_request_for( string $filename ): bool { return Request_Path::matches( $filename ); } /** * Preserved for backward compatibility with existing tests that reference * the old private method name. */ private function is_llms_txt_request(): bool { return $this->is_request_for( 'llms.txt' ); } /** * Return the feature registry so external code can query registered components. */ public function get_feature_registry(): Feature_Registry { return $this->feature_registry; } public function is_browser_tools_module_active(): bool { return $this->settings->is_module_active( Agent_Ready_Settings::MODULE_BROWSER_TOOLS ); } /** * Add a feature component to the registry and to the base module's * component store, then call register() if the component is enabled. */ private function register_component( Feature_Component $component ): void { $this->feature_registry->register( $component ); $this->add_component( $component->get_id(), $component ); $required_modules = $component->get_required_modules(); $should_register = $component->is_enabled(); if ( $should_register && ! empty( $required_modules ) ) { $should_register = $this->settings->has_any_active( $required_modules ); } if ( $should_register ) { $component->register(); } } /** * Register a well-known endpoint component. * * Adds it to the feature registry, the module's component store, and the * well-known router. The router handles dispatch — the component's own * register() is a no-op. Disabled/inapplicable endpoints are still * registered so the router and registry are always complete. * * @param \Elementor\Modules\Agents\Components\Discovery\Well_Known\Abstract_Well_Known_Endpoint $endpoint */ private function register_well_known_endpoint( $endpoint ): void { $this->feature_registry->register( $endpoint ); $this->add_component( $endpoint->get_id(), $endpoint ); $this->well_known_router->register_endpoint( $endpoint ); } }