<?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 );
}
}