diff --git a/includes/abilities-manifest.php b/includes/abilities-manifest.php index 975ab7b..9708167 100644 --- a/includes/abilities-manifest.php +++ b/includes/abilities-manifest.php @@ -67,12 +67,6 @@ function agentic_admin_core_abilities(): array { // Knowledge. 'web-search' => 'agentic_admin_register_web_search', - // RAG infrastructure (used by the knowledge base — always on). - 'schema-extract' => 'agentic_admin_register_schema_extract', - 'wp-api-extract' => 'agentic_admin_register_wp_api_extract', - 'docs-extract' => 'agentic_admin_register_docs_extract', - 'codebase-extract' => 'agentic_admin_register_codebase_extract', - // Plugin abilities platform — lets the assistant discover and run // abilities that other plugins register via the WordPress Abilities API. 'discover-plugin-abilities' => 'agentic_admin_register_discover_plugin_abilities', @@ -84,14 +78,12 @@ function agentic_admin_core_abilities(): array { * Local-only abilities — registered server-side, but the JS layer hides * them from the LLM whenever an external AI provider is active. * - * Note: wp-config-list is JS-only (no PHP register function) so it lives - * only in the JS manifest. - * * @return array */ function agentic_admin_local_only_abilities(): array { return array( - 'read-file' => 'agentic_admin_register_read_file', + 'read-file' => 'agentic_admin_register_read_file', + 'wp-config-list' => 'agentic_admin_register_wp_config_list', ); } diff --git a/includes/abilities/codebase-extract.php b/includes/abilities/codebase-extract.php deleted file mode 100644 index 0b9f8b4..0000000 --- a/includes/abilities/codebase-extract.php +++ /dev/null @@ -1,522 +0,0 @@ - __( 'Extract Codebase', 'agentic-admin' ), - 'description' => __( 'Extract code chunks from active theme and plugins for indexing.', 'agentic-admin' ), - 'category' => 'sre-tools', - 'input_schema' => array( - 'type' => 'object', - 'default' => array( - 'offset' => 0, - 'limit' => 50, - ), - 'properties' => array( - 'offset' => array( - 'type' => 'integer', - 'default' => 0, - 'description' => __( 'File offset for pagination.', 'agentic-admin' ), - ), - 'limit' => array( - 'type' => 'integer', - 'default' => 50, - 'description' => __( 'Max files per page.', 'agentic-admin' ), - ), - ), - 'additionalProperties' => false, - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'chunks' => array( - 'type' => 'array', - 'description' => __( 'Code chunks with path, line range, and content.', 'agentic-admin' ), - ), - 'total_files' => array( - 'type' => 'integer', - 'description' => __( 'Total number of scannable files.', 'agentic-admin' ), - ), - 'has_more' => array( - 'type' => 'boolean', - 'description' => __( 'Whether more pages are available.', 'agentic-admin' ), - ), - ), - ), - 'execute_callback' => 'agentic_admin_execute_codebase_extract', - 'permission_callback' => function () { - return current_user_can( 'manage_options' ); - }, - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - ), - // JS configuration for chat interface. - array( - 'keywords' => array( 'extract', 'codebase', 'code', 'source' ), - 'initialMessage' => __( 'Extracting code from your site...', 'agentic-admin' ), - ) - ); -} - -/** - * Collect all scannable files from active theme and plugins. - * - * @return string[] Array of absolute file paths. - */ -function agentic_admin_collect_code_files(): array { - $files = array(); - - // Skip directories. - $skip_dirs = array( 'node_modules', 'vendor', 'build', 'dist', '.git', 'tests', 'test' ); - - // Skip file patterns. - $skip_patterns = array( '.min.js', '.min.css', '.map', '.lock' ); - - // Allowed extensions. - $extensions = array( 'php', 'js' ); - - // Max file size: 100KB. - $max_size = 100 * 1024; - - // 1. Active theme. - $theme_dir = get_stylesheet_directory(); - if ( is_dir( $theme_dir ) ) { - $files = array_merge( - $files, - agentic_admin_scan_directory( $theme_dir, $extensions, $skip_dirs, $skip_patterns, $max_size ) - ); - } - - // 2. Active plugins (exclude agentic-admin itself). - $active_plugins = get_option( 'active_plugins', array() ); - $plugins_dir = WP_PLUGIN_DIR; - - foreach ( $active_plugins as $plugin_file ) { - $plugin_slug = dirname( $plugin_file ); - - // Skip single-file plugins (no directory). - if ( '.' === $plugin_slug ) { - // Include the single file directly. - $single_file = $plugins_dir . '/' . $plugin_file; - if ( file_exists( $single_file ) && filesize( $single_file ) <= $max_size ) { - $files[] = $single_file; - } - continue; - } - - // Skip ourselves. - if ( 'agentic-admin' === $plugin_slug ) { - continue; - } - - $plugin_dir = $plugins_dir . '/' . $plugin_slug; - if ( is_dir( $plugin_dir ) ) { - $files = array_merge( - $files, - agentic_admin_scan_directory( $plugin_dir, $extensions, $skip_dirs, $skip_patterns, $max_size ) - ); - } - } - - sort( $files ); - return $files; -} - -/** - * Recursively scan a directory for code files. - * - * @param string $dir Directory to scan. - * @param string[] $extensions Allowed file extensions. - * @param string[] $skip_dirs Directory names to skip. - * @param string[] $skip_patterns File name patterns to skip. - * @param int $max_size Maximum file size in bytes. - * @param int $depth Current recursion depth. - * @return string[] Array of file paths. - */ -function agentic_admin_scan_directory( - string $dir, - array $extensions, - array $skip_dirs, - array $skip_patterns, - int $max_size, - int $depth = 0 -): array { - $files = array(); - - if ( $depth > 8 || ! is_dir( $dir ) || ! is_readable( $dir ) ) { - return $files; - } - - $iterator = new DirectoryIterator( $dir ); - - foreach ( $iterator as $item ) { - if ( $item->isDot() ) { - continue; - } - - $name = $item->getFilename(); - - if ( $item->isDir() ) { - if ( in_array( $name, $skip_dirs, true ) ) { - continue; - } - $files = array_merge( - $files, - agentic_admin_scan_directory( - $item->getPathname(), - $extensions, - $skip_dirs, - $skip_patterns, - $max_size, - $depth + 1 - ) - ); - continue; - } - - if ( ! $item->isFile() ) { - continue; - } - - // Check extension. - $ext = strtolower( $item->getExtension() ); - if ( ! in_array( $ext, $extensions, true ) ) { - continue; - } - - // Check skip patterns. - $skip = false; - foreach ( $skip_patterns as $pattern ) { - if ( str_ends_with( $name, $pattern ) ) { - $skip = true; - break; - } - } - if ( $skip ) { - continue; - } - - // Check size. - if ( $item->getSize() > $max_size ) { - continue; - } - - $files[] = $item->getPathname(); - } - - return $files; -} - -/** - * Chunk a file's content by function/class boundaries. - * - * @param string $file_path Absolute path to the file. - * @param string $base_path Base path to strip for relative paths. - * @return array Array of chunk arrays with path, start_line, end_line, content, type. - */ -function agentic_admin_chunk_file( string $file_path, string $base_path ): array { - global $wp_filesystem; - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - WP_Filesystem(); - } - $content = $wp_filesystem->get_contents( $file_path ); - if ( false === $content ) { - return array(); - } - - $relative_path = str_replace( $base_path, '', $file_path ); - $relative_path = ltrim( $relative_path, '/' ); - $lines = explode( "\n", $content ); - $total_lines = count( $lines ); - - // For small files (< 50 lines), return as a single chunk. - if ( $total_lines < 50 ) { - return array( - array( - 'path' => $relative_path, - 'start_line' => 1, - 'end_line' => $total_lines, - 'content' => $content, - 'type' => 'file', - ), - ); - } - - $ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) ); - $chunks = array(); - - if ( 'php' === $ext ) { - $chunks = agentic_admin_chunk_php( $lines, $relative_path ); - } elseif ( 'js' === $ext ) { - $chunks = agentic_admin_chunk_js( $lines, $relative_path ); - } - - // Fallback: if no chunks found, split into ~40-line blocks. - if ( empty( $chunks ) ) { - $chunks = agentic_admin_chunk_by_lines( $lines, $relative_path, 40 ); - } - - return $chunks; -} - -/** - * Chunk PHP code by function/class boundaries. - * - * @param string[] $lines Array of lines. - * @param string $relative_path Relative file path. - * @return array Array of chunks. - */ -function agentic_admin_chunk_php( array $lines, string $relative_path ): array { - $chunks = array(); - $current_start = 0; - $brace_depth = 0; - $in_block = false; - $block_type = 'code'; - $line_count = count( $lines ); - - for ( $i = 0; $i < $line_count; $i++ ) { - $line = $lines[ $i ]; - $trimmed = trim( $line ); - - // Detect function/class/interface/trait start. - if ( ! $in_block && preg_match( '/^(abstract\s+)?(class|interface|trait|function)\s+/i', $trimmed ) ) { - // Save preceding code as a chunk if significant. - if ( $i - $current_start > 2 ) { - $chunk_lines = array_slice( $lines, $current_start, $i - $current_start ); - $chunk_text = implode( "\n", $chunk_lines ); - if ( strlen( trim( $chunk_text ) ) > 20 ) { - $chunks[] = array( - 'path' => $relative_path, - 'start_line' => $current_start + 1, - 'end_line' => $i, - 'content' => $chunk_text, - 'type' => 'code', - ); - } - } - $current_start = $i; - $in_block = true; - $brace_depth = 0; - - if ( preg_match( '/^(abstract\s+)?(class|interface|trait)\s+/i', $trimmed ) ) { - $block_type = 'class'; - } else { - $block_type = 'function'; - } - } - - // Track brace depth. - $brace_depth += substr_count( $line, '{' ); - $brace_depth -= substr_count( $line, '}' ); - - // End of block: braces balanced back to 0. - if ( $in_block && $brace_depth <= 0 && strpos( $line, '}' ) !== false ) { - $chunk_lines = array_slice( $lines, $current_start, $i - $current_start + 1 ); - $chunks[] = array( - 'path' => $relative_path, - 'start_line' => $current_start + 1, - 'end_line' => $i + 1, - 'content' => implode( "\n", $chunk_lines ), - 'type' => $block_type, - ); - $current_start = $i + 1; - $in_block = false; - $brace_depth = 0; - } - } - - // Remaining lines. - if ( $current_start < $line_count ) { - $chunk_lines = array_slice( $lines, $current_start ); - $chunk_text = implode( "\n", $chunk_lines ); - if ( strlen( trim( $chunk_text ) ) > 20 ) { - $chunks[] = array( - 'path' => $relative_path, - 'start_line' => $current_start + 1, - 'end_line' => $line_count, - 'content' => $chunk_text, - 'type' => 'code', - ); - } - } - - return $chunks; -} - -/** - * Chunk JavaScript code by function/class/export boundaries. - * - * @param string[] $lines Array of lines. - * @param string $relative_path Relative file path. - * @return array Array of chunks. - */ -function agentic_admin_chunk_js( array $lines, string $relative_path ): array { - $chunks = array(); - $current_start = 0; - $brace_depth = 0; - $in_block = false; - $block_type = 'code'; - $line_count = count( $lines ); - - for ( $i = 0; $i < $line_count; $i++ ) { - $line = $lines[ $i ]; - $trimmed = trim( $line ); - - // Detect function/class/export start. - if ( ! $in_block && preg_match( '/^(export\s+)?(default\s+)?(async\s+)?(function|class|const|let|var)\s+/i', $trimmed ) ) { - // Save preceding code as a chunk if significant. - if ( $i - $current_start > 2 ) { - $chunk_lines = array_slice( $lines, $current_start, $i - $current_start ); - $chunk_text = implode( "\n", $chunk_lines ); - if ( strlen( trim( $chunk_text ) ) > 20 ) { - $chunks[] = array( - 'path' => $relative_path, - 'start_line' => $current_start + 1, - 'end_line' => $i, - 'content' => $chunk_text, - 'type' => 'code', - ); - } - } - $current_start = $i; - $in_block = true; - $brace_depth = 0; - - if ( preg_match( '/class\s+/i', $trimmed ) ) { - $block_type = 'class'; - } else { - $block_type = 'function'; - } - } - - // Track brace depth. - $brace_depth += substr_count( $line, '{' ); - $brace_depth -= substr_count( $line, '}' ); - - // End of block: braces balanced back to 0. - if ( $in_block && $brace_depth <= 0 && strpos( $line, '}' ) !== false ) { - $chunk_lines = array_slice( $lines, $current_start, $i - $current_start + 1 ); - $chunks[] = array( - 'path' => $relative_path, - 'start_line' => $current_start + 1, - 'end_line' => $i + 1, - 'content' => implode( "\n", $chunk_lines ), - 'type' => $block_type, - ); - $current_start = $i + 1; - $in_block = false; - $brace_depth = 0; - } - } - - // Remaining lines. - if ( $current_start < $line_count ) { - $chunk_lines = array_slice( $lines, $current_start ); - $chunk_text = implode( "\n", $chunk_lines ); - if ( strlen( trim( $chunk_text ) ) > 20 ) { - $chunks[] = array( - 'path' => $relative_path, - 'start_line' => $current_start + 1, - 'end_line' => $line_count, - 'content' => $chunk_text, - 'type' => 'code', - ); - } - } - - return $chunks; -} - -/** - * Chunk content into fixed-size line blocks. - * - * @param string[] $lines Array of lines. - * @param string $relative_path Relative file path. - * @param int $block_size Lines per block. - * @return array Array of chunks. - */ -function agentic_admin_chunk_by_lines( array $lines, string $relative_path, int $block_size = 40 ): array { - $chunks = array(); - $total = count( $lines ); - - for ( $i = 0; $i < $total; $i += $block_size ) { - $chunk_lines = array_slice( $lines, $i, $block_size ); - $chunk_text = implode( "\n", $chunk_lines ); - - if ( strlen( trim( $chunk_text ) ) > 20 ) { - $chunks[] = array( - 'path' => $relative_path, - 'start_line' => $i + 1, - 'end_line' => min( $i + $block_size, $total ), - 'content' => $chunk_text, - 'type' => 'block', - ); - } - } - - return $chunks; -} - -/** - * Execute the codebase-extract ability. - * - * @param array $input Input parameters. - * @return array - */ -function agentic_admin_execute_codebase_extract( array $input = array() ): array { - $offset = isset( $input['offset'] ) ? max( 0, absint( $input['offset'] ) ) : 0; - $limit = isset( $input['limit'] ) ? min( absint( $input['limit'] ), 100 ) : 50; - - $all_files = agentic_admin_collect_code_files(); - $total_files = count( $all_files ); - - // Paginate files. - $page_files = array_slice( $all_files, $offset, $limit ); - $has_more = ( $offset + $limit ) < $total_files; - - // Determine base path for relative paths. - $wp_content_dir = WP_CONTENT_DIR; - - $chunks = array(); - - foreach ( $page_files as $file_path ) { - $file_chunks = agentic_admin_chunk_file( $file_path, $wp_content_dir ); - foreach ( $file_chunks as $chunk ) { - $chunks[] = $chunk; - } - } - - return array( - 'chunks' => $chunks, - 'total_files' => $total_files, - 'files_page' => count( $page_files ), - 'offset' => $offset, - 'has_more' => $has_more, - ); -} diff --git a/includes/abilities/docs-extract.php b/includes/abilities/docs-extract.php deleted file mode 100644 index 93c8854..0000000 --- a/includes/abilities/docs-extract.php +++ /dev/null @@ -1,181 +0,0 @@ - __( 'Extract Reference Docs', 'agentic-admin' ), - 'description' => __( 'Extract bundled WordPress reference documentation for knowledge base indexing.', 'agentic-admin' ), - 'category' => 'sre-tools', - 'input_schema' => array( - 'type' => 'object', - 'default' => array(), - 'properties' => array(), - 'additionalProperties' => false, - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'chunks' => array( - 'type' => 'array', - 'description' => __( 'Doc section chunks.', 'agentic-admin' ), - ), - 'total_files' => array( - 'type' => 'integer', - 'description' => __( 'Total doc files processed.', 'agentic-admin' ), - ), - ), - ), - 'execute_callback' => 'agentic_admin_execute_docs_extract', - 'permission_callback' => function () { - return current_user_can( 'manage_options' ); - }, - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - ), - // JS configuration for chat interface. - array( - 'keywords' => array( 'docs', 'documentation', 'reference', 'extract docs' ), - 'initialMessage' => __( 'Extracting reference documentation...', 'agentic-admin' ), - ) - ); -} - -/** - * Chunk a markdown file by heading boundaries (## or ###). - * - * @param string $content File content. - * @param string $file_name File name for chunk path. - * @return array Array of chunk arrays. - */ -function agentic_admin_chunk_markdown( string $content, string $file_name ): array { - $lines = explode( "\n", $content ); - $chunks = array(); - $current = array(); - $current_heading = ''; - $start_line = 1; - - foreach ( $lines as $idx => $line ) { - // Detect ## or ### headings (not # which is the doc title). - if ( preg_match( '/^#{2,3}\s+(.+)$/', $line, $matches ) ) { - // Save previous section if it has content. - if ( ! empty( $current ) ) { - $section_text = implode( "\n", $current ); - if ( strlen( trim( $section_text ) ) > 20 ) { - $chunks[] = array( - 'path' => 'docs://' . $file_name, - 'start_line' => $start_line, - 'end_line' => $idx, - 'content' => $section_text, - 'type' => 'docs', - ); - } - } - $current = array( $line ); - $current_heading = $matches[1]; - $start_line = $idx + 1; - } else { - $current[] = $line; - } - } - - // Save last section. - if ( ! empty( $current ) ) { - $section_text = implode( "\n", $current ); - if ( strlen( trim( $section_text ) ) > 20 ) { - $chunks[] = array( - 'path' => 'docs://' . $file_name, - 'start_line' => $start_line, - 'end_line' => count( $lines ), - 'content' => $section_text, - 'type' => 'docs', - ); - } - } - - return $chunks; -} - -/** - * Execute the docs-extract ability. - * - * @param array $input Input parameters (unused). - * @return array - */ -function agentic_admin_execute_docs_extract( array $input = array() ): array { - global $wp_filesystem; - - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - WP_Filesystem(); - } - - $docs_dir = AGENTIC_ADMIN_PLUGIN_DIR . 'docs/knowledge/'; - - if ( ! is_dir( $docs_dir ) ) { - return array( - 'success' => false, - 'message' => 'Knowledge docs directory not found.', - 'chunks' => array(), - 'total_files' => 0, - ); - } - - $md_files = glob( $docs_dir . '*.md' ); - - if ( empty( $md_files ) ) { - return array( - 'success' => false, - 'message' => 'No markdown files found in docs/knowledge/.', - 'chunks' => array(), - 'total_files' => 0, - ); - } - - $chunks = array(); - $total_files = 0; - - foreach ( $md_files as $file_path ) { - $content = $wp_filesystem->get_contents( $file_path ); - - if ( false === $content || empty( trim( $content ) ) ) { - continue; - } - - $file_name = basename( $file_path, '.md' ); - $file_chunks = agentic_admin_chunk_markdown( $content, $file_name ); - $chunks = array_merge( $chunks, $file_chunks ); - ++$total_files; - } - - return array( - 'success' => true, - 'chunks' => $chunks, - 'total_files' => $total_files, - ); -} diff --git a/includes/abilities/read-file.php b/includes/abilities/read-file.php index 7c90976..17e8fbc 100644 --- a/includes/abilities/read-file.php +++ b/includes/abilities/read-file.php @@ -23,12 +23,12 @@ function agentic_admin_register_read_file(): void { // PHP configuration for WordPress Abilities API. array( 'label' => __( 'Read File', 'agentic-admin' ), - 'description' => __( 'Read a WordPress file with sensitive data (credentials, keys, salts) automatically redacted.', 'agentic-admin' ), + 'description' => __( 'Read a WordPress file. Files that can hold credentials, keys, or salts (such as wp-config.php) are never read.', 'agentic-admin' ), 'category' => 'sre-tools', 'input_schema' => array( 'type' => 'object', 'default' => array( - 'file_path' => 'wp-config.php', + 'file_path' => '.htaccess', 'offset' => 0, 'lines' => 100, ), @@ -36,7 +36,7 @@ function agentic_admin_register_read_file(): void { 'properties' => array( 'file_path' => array( 'type' => 'string', - 'description' => __( 'Path to the file relative to ABSPATH (e.g. wp-config.php or wp-content/themes/mytheme/functions.php).', 'agentic-admin' ), + 'description' => __( 'Path to the file relative to the site root (e.g. .htaccess or wp-content/themes/mytheme/functions.php).', 'agentic-admin' ), ), 'offset' => array( 'type' => 'integer', @@ -67,7 +67,7 @@ function agentic_admin_register_read_file(): void { ), 'file_path' => array( 'type' => 'string', - 'description' => __( 'Resolved file path relative to ABSPATH.', 'agentic-admin' ), + 'description' => __( 'Resolved file path relative to the site root.', 'agentic-admin' ), ), 'content' => array( 'type' => 'string', @@ -102,7 +102,7 @@ function agentic_admin_register_read_file(): void { ), // JS configuration for chat interface. array( - 'keywords' => array( 'read', 'show', 'view', 'open', 'display', 'cat', 'file', 'config', 'htaccess', 'functions.php', 'wp-config', 'wp-config.php', 'contents', 'source' ), + 'keywords' => array( 'read', 'show', 'view', 'open', 'display', 'cat', 'file', 'config', 'htaccess', 'functions.php', 'contents', 'source' ), 'initialMessage' => __( "I'll read that file for you...", 'agentic-admin' ), ) ); @@ -127,8 +127,12 @@ function agentic_admin_execute_read_file( array $input = array() ): array { ); } - // Resolve the absolute path: support both absolute paths and paths relative to ABSPATH. - $abspath = wp_normalize_path( ABSPATH ); + // Resolve the absolute path: support both absolute paths and paths + // relative to the site root (get_home_path()). + if ( ! function_exists( 'get_home_path' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + $site_root = trailingslashit( wp_normalize_path( get_home_path() ) ); // Detect absolute paths: starts with / (Unix) or drive letter (Windows e.g. C:\). $is_absolute = ( '/' === $raw_path[0] ) || ( strlen( $raw_path ) > 2 && ':' === $raw_path[1] ); @@ -137,13 +141,13 @@ function agentic_admin_execute_read_file( array $input = array() ): array { $absolute_path = wp_normalize_path( $raw_path ); } else { // For bare filenames with no directory component, try common WordPress locations. - // e.g. "functions.php" → active theme, "style.css" → active theme, "wp-config.php" → ABSPATH. + // e.g. "functions.php" → active theme, "style.css" → active theme, ".htaccess" → site root. $bare_name = basename( $raw_path ); if ( $bare_name === $raw_path && false === strpos( $raw_path, '/' ) ) { - $absolute_path = agentic_admin_resolve_bare_filename( $raw_path, $abspath ); + $absolute_path = agentic_admin_resolve_bare_filename( $raw_path, $site_root ); } else { // Strip any leading slash before joining. - $absolute_path = wp_normalize_path( $abspath . ltrim( $raw_path, '/\\' ) ); + $absolute_path = wp_normalize_path( $site_root . ltrim( $raw_path, '/\\' ) ); } } @@ -161,11 +165,11 @@ function agentic_admin_execute_read_file( array $input = array() ): array { ); } - $real_path = wp_normalize_path( $real_path ); - $real_abspath = wp_normalize_path( realpath( ABSPATH ) ); + $real_path = wp_normalize_path( $real_path ); + $real_root = trailingslashit( wp_normalize_path( (string) realpath( $site_root ) ) ); - // Security: ensure the file is within the WordPress root directory. - if ( ! str_starts_with( $real_path, $real_abspath ) ) { + // Security: ensure the file is within the site root directory. + if ( '/' === $real_root || ! str_starts_with( $real_path, $real_root ) ) { return array( 'success' => false, 'message' => __( 'Access denied: file is outside the WordPress root directory.', 'agentic-admin' ), @@ -186,6 +190,16 @@ function agentic_admin_execute_read_file( array $input = array() ): array { ); } + // Security: never return files that hold credentials, keys, or salts. + // These are refused outright rather than redacted, because pattern-based + // redaction cannot reliably catch every way a secret can be written. + if ( agentic_admin_is_secret_file( $real_path ) ) { + return array( + 'success' => false, + 'message' => __( 'Access denied: this file can contain credentials, authentication keys, or salts, so it is never read. Use the wp-config-list ability to see non-sensitive configuration constants.', 'agentic-admin' ), + ); + } + // Read the file lines. $file = new \SplFileObject( $real_path, 'r' ); $file->seek( PHP_INT_MAX ); @@ -211,7 +225,7 @@ function agentic_admin_execute_read_file( array $input = array() ): array { // Redact sensitive values before the content leaves PHP. list( $content, $was_redacted ) = agentic_admin_redact_sensitive_data( $content ); - $relative_path = ltrim( str_replace( $real_abspath, '', $real_path ), '/' ); + $relative_path = substr( $real_path, strlen( $real_root ) ); return array( 'success' => true, @@ -231,18 +245,18 @@ function agentic_admin_execute_read_file( array $input = array() ): array { /** * Resolve a bare filename (no directory) to its most likely WordPress path. * - * Tries ABSPATH first, then the active theme directory, so that e.g. - * "functions.php" resolves to the theme rather than the WordPress root. + * Root-level files resolve to the site root; anything else tries the active + * theme directory, so that e.g. "functions.php" resolves to the theme. * * @param string $filename Bare filename (e.g. 'functions.php'). - * @param string $abspath Normalised ABSPATH. + * @param string $site_root Normalised site root with trailing slash. * @return string Absolute path to the best candidate (may not exist). */ -function agentic_admin_resolve_bare_filename( string $filename, string $abspath ): string { +function agentic_admin_resolve_bare_filename( string $filename, string $site_root ): string { // Always-at-root files. - $root_files = array( 'wp-config.php', '.htaccess', 'robots.txt', 'wp-login.php', 'wp-cron.php', 'xmlrpc.php', 'index.php' ); + $root_files = array( '.htaccess', 'robots.txt', 'wp-login.php', 'wp-cron.php', 'xmlrpc.php', 'index.php' ); if ( in_array( $filename, $root_files, true ) ) { - return wp_normalize_path( $abspath . $filename ); + return wp_normalize_path( $site_root . $filename ); } // Theme files: check active (child) theme, then parent theme. @@ -256,8 +270,50 @@ function agentic_admin_resolve_bare_filename( string $filename, string $abspath } } - // Fall back to ABSPATH root. - return wp_normalize_path( $abspath . $filename ); + // Fall back to the site root. + return wp_normalize_path( $site_root . $filename ); +} + +/** + * Whether a file must never be returned because it can hold secrets. + * + * Matches by name first (wp-config.php and its variants, .env files, + * password files, private keys, database dumps), then scans the whole file + * for define() calls of the authentication keys and salts or the database + * password, so a secrets file with an unexpected name is refused too. + * + * @param string $real_path Canonical absolute path of the file. + * @return bool True when the file must not be read. + */ +function agentic_admin_is_secret_file( string $real_path ): bool { + $name = strtolower( basename( $real_path ) ); + + if ( preg_match( '/^wp-config.*\.php$/', $name ) ) { + return true; + } + + if ( str_starts_with( $name, '.env' ) || in_array( $name, array( '.htpasswd', 'auth.json', '.netrc', '.pgpass', '.my.cnf' ), true ) ) { + return true; + } + + if ( preg_match( '/^id_(rsa|dsa|ecdsa|ed25519)/', $name ) || preg_match( '/\.(pem|key|p12|pfx|sql|sqlite|sqlite3|db)$/', $name ) ) { + return true; + } + + if ( preg_match( '#/\.(git|ssh)/#', wp_normalize_path( $real_path ) ) ) { + return true; + } + + // Content check over the whole file, not just the requested line range. + $handle = new \SplFileObject( $real_path, 'r' ); + while ( ! $handle->eof() ) { + $line = (string) $handle->fgets(); + if ( preg_match( '/define\s*\(\s*[\'"](?:(?:SECURE_AUTH|LOGGED_IN|AUTH|NONCE)_(?:KEY|SALT)|DB_PASSWORD)[\'"]/i', $line ) ) { + return true; + } + } + + return false; } /** diff --git a/includes/abilities/schema-extract.php b/includes/abilities/schema-extract.php deleted file mode 100644 index e7f3654..0000000 --- a/includes/abilities/schema-extract.php +++ /dev/null @@ -1,205 +0,0 @@ - __( 'Extract Database Schema', 'agentic-admin' ), - 'description' => __( 'Extract database table schemas for knowledge base indexing.', 'agentic-admin' ), - 'category' => 'sre-tools', - 'input_schema' => array( - 'type' => 'object', - 'default' => array(), - 'properties' => array(), - 'additionalProperties' => false, - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'chunks' => array( - 'type' => 'array', - 'description' => __( 'Schema chunks with table info.', 'agentic-admin' ), - ), - 'total_tables' => array( - 'type' => 'integer', - 'description' => __( 'Total tables extracted.', 'agentic-admin' ), - ), - ), - ), - 'execute_callback' => 'agentic_admin_execute_schema_extract', - 'permission_callback' => function () { - return current_user_can( 'manage_options' ); - }, - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - ), - // JS configuration for chat interface. - array( - 'keywords' => array( 'schema', 'database', 'tables', 'extract schema' ), - 'initialMessage' => __( 'Extracting database schema...', 'agentic-admin' ), - ) - ); -} - -/** - * Get semantic context descriptions for core WordPress tables. - * - * @return array Map of short table name to description. - */ -function agentic_admin_get_core_table_context(): array { - return array( - 'posts' => 'Stores blog posts, pages, custom post types, and revisions. Each row is a content item with title, content, status, author, and dates.', - 'postmeta' => 'Key-value metadata for posts. Stores custom fields, plugin data, and internal WordPress post metadata.', - 'comments' => 'Stores comments on posts. Includes author info, content, approval status, and parent comment for threading.', - 'commentmeta' => 'Key-value metadata for comments. Used by plugins like Akismet for spam detection data.', - 'terms' => 'Stores taxonomy terms (categories, tags, custom taxonomy terms). Contains term name and slug.', - 'term_taxonomy' => 'Links terms to taxonomies. Stores the taxonomy type (category, post_tag, etc.) and hierarchy (parent).', - 'term_relationships' => 'Links posts to taxonomy terms. Maps object IDs (posts) to term_taxonomy_id entries.', - 'termmeta' => 'Key-value metadata for taxonomy terms.', - 'users' => 'WordPress user accounts. Stores login, display name, and registration date.', - 'usermeta' => 'Key-value metadata for users. Stores roles, capabilities, preferences, and plugin user data.', - 'options' => 'Site-wide settings and configuration. Key-value store for WordPress settings, plugin options, and widget data. autoload column controls which options load on every page.', - 'links' => 'Blogroll links (legacy feature, rarely used in modern WordPress).', - ); -} - -/** - * Execute the schema-extract ability. - * - * Runs SHOW TABLES, then DESCRIBE + SHOW INDEX for each table. - * Redacts sensitive columns (user_pass, user_email, etc.). - * - * @param array $input Input parameters (unused). - * @return array - */ -function agentic_admin_execute_schema_extract( array $input = array() ): array { - global $wpdb; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $tables = $wpdb->get_col( 'SHOW TABLES' ); - - if ( empty( $tables ) ) { - return array( - 'success' => false, - 'message' => 'No tables found.', - 'chunks' => array(), - 'total_tables' => 0, - ); - } - - $core_context = agentic_admin_get_core_table_context(); - $sensitive_columns = array( 'user_pass', 'user_email', 'user_activation_key', 'session_tokens' ); - $prefix = $wpdb->prefix; - $chunks = array(); - - foreach ( $tables as $table ) { - // Validate table name to prevent SQL injection — only allow alphanumeric + underscore. - if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) { - continue; - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $columns = $wpdb->get_results( "DESCRIBE `{$table}`", ARRAY_A ); - - if ( empty( $columns ) ) { - continue; - } - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $indexes = $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ); - - // Build human-readable schema. - $schema_lines = array(); - $schema_lines[] = "Table: {$table}"; - - // Add semantic context for core WP tables. - $short_name = str_replace( $prefix, '', $table ); - if ( isset( $core_context[ $short_name ] ) ) { - $schema_lines[] = 'Purpose: ' . $core_context[ $short_name ]; - } - - $schema_lines[] = ''; - $schema_lines[] = 'Columns:'; - - foreach ( $columns as $col ) { - $col_name = $col['Field']; - - // Redact sensitive columns. - if ( in_array( $col_name, $sensitive_columns, true ) ) { - $schema_lines[] = " {$col_name}: [REDACTED] ({$col['Type']})"; - continue; - } - - $nullable = 'YES' === $col['Null'] ? ', nullable' : ''; - $default = null !== $col['Default'] ? ", default: {$col['Default']}" : ''; - $key = $col['Key'] ? ", key: {$col['Key']}" : ''; - $extra = $col['Extra'] ? ", {$col['Extra']}" : ''; - - $schema_lines[] = " {$col_name}: {$col['Type']}{$nullable}{$default}{$key}{$extra}"; - } - - // Add index info. - if ( ! empty( $indexes ) ) { - $index_groups = array(); - - foreach ( $indexes as $idx ) { - $idx_name = $idx['Key_name']; - if ( ! isset( $index_groups[ $idx_name ] ) ) { - $index_groups[ $idx_name ] = array( - 'unique' => ! $idx['Non_unique'], - 'columns' => array(), - ); - } - $index_groups[ $idx_name ]['columns'][] = $idx['Column_name']; - } - - $schema_lines[] = ''; - $schema_lines[] = 'Indexes:'; - - foreach ( $index_groups as $idx_name => $idx_info ) { - $type = $idx_info['unique'] ? 'UNIQUE' : 'INDEX'; - $cols = implode( ', ', $idx_info['columns'] ); - $schema_lines[] = " {$idx_name} ({$type}): {$cols}"; - } - } - - $chunks[] = array( - 'path' => "db://{$table}", - 'start_line' => 1, - 'end_line' => count( $schema_lines ), - 'content' => implode( "\n", $schema_lines ), - 'type' => 'schema', - ); - } - - return array( - 'success' => true, - 'chunks' => $chunks, - 'total_tables' => count( $tables ), - ); -} diff --git a/includes/abilities/security-scan.php b/includes/abilities/security-scan.php index 09c844f..dae5f77 100644 --- a/includes/abilities/security-scan.php +++ b/includes/abilities/security-scan.php @@ -95,8 +95,8 @@ function agentic_admin_execute_security_scan( array $input = array() ): array { ); // 3. Check wp-config.php file permissions. - $wp_config = ABSPATH . 'wp-config.php'; - if ( file_exists( $wp_config ) ) { + $wp_config = agentic_admin_locate_wp_config(); + if ( '' !== $wp_config ) { $perms = fileperms( $wp_config ) & 0777; $is_secure = $perms <= 0640; $checks[] = array( @@ -109,19 +109,7 @@ function agentic_admin_execute_security_scan( array $input = array() ): array { ); } - // 4. Check for default auth salts. - $salt = wp_salt( 'auth' ); - $is_default = ( 'put your unique phrase here' === $salt ); - $checks[] = array( - 'check' => 'Authentication salts', - 'status' => $is_default ? 'fail' : 'pass', - 'severity' => 'critical', - 'message' => $is_default - ? 'Using default salts. Generate new ones immediately.' - : 'Custom salts are configured.', - ); - - // 5. Check WordPress version exposure. + // 4. Check WordPress version exposure. $checks[] = array( 'check' => 'Version in HTML', 'status' => has_action( 'wp_head', 'wp_generator' ) ? 'fail' : 'pass', @@ -131,7 +119,7 @@ function agentic_admin_execute_security_scan( array $input = array() ): array { : 'WordPress version is hidden.', ); - // 6. Check directory listing. + // 5. Check directory listing. $uploads_dir = wp_upload_dir(); $uploads_path = $uploads_dir['basedir']; $htaccess = $uploads_path . '/.htaccess'; diff --git a/includes/abilities/security/file-scan.php b/includes/abilities/security/file-scan.php index 79a41eb..59c3361 100644 --- a/includes/abilities/security/file-scan.php +++ b/includes/abilities/security/file-scan.php @@ -204,11 +204,11 @@ function agentic_admin_execute_file_scan( array $input = array() ): array { $dirs_to_scan = array(); if ( $scan_plugins ) { - $dirs_to_scan[] = WP_PLUGIN_DIR; + $dirs_to_scan[] = agentic_admin_plugins_dir(); - // Also scan must-use plugins if the directory exists. - $mu_dir = WPMU_PLUGIN_DIR; - if ( is_dir( $mu_dir ) ) { + // Also scan must-use plugins if any are installed. + $mu_dir = agentic_admin_mu_plugins_dir(); + if ( '' !== $mu_dir && is_dir( $mu_dir ) ) { $dirs_to_scan[] = $mu_dir; } } @@ -286,9 +286,9 @@ function ( $current, $key, $iterator ) use ( $skip_dirs, $excluded_paths ) { ); $normalized_dir = wp_normalize_path( $dir ); - $is_plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ) === $normalized_dir; - $mu_plugin_dir = WPMU_PLUGIN_DIR; - $is_mu_dir = wp_normalize_path( $mu_plugin_dir ) === $normalized_dir; + $is_plugin_dir = agentic_admin_plugins_dir() === $normalized_dir; + $mu_plugin_dir = agentic_admin_mu_plugins_dir(); + $is_mu_dir = '' !== $mu_plugin_dir && $mu_plugin_dir === $normalized_dir; foreach ( $iterator as $file_info ) { if ( ! $file_info->isFile() ) { @@ -506,13 +506,17 @@ function ( $a, $b ) { } /** - * Get a file path relative to wp-content for display. + * Get a file path relative to the content directory for display. + * + * The content directory is taken as the parent of the plugins directory; + * paths outside it are returned unchanged. * * @param string $absolute_path Absolute file path. - * @return string Relative path from wp-content. + * @return string Relative path from the content directory. */ function agentic_admin_get_content_relative_path( string $absolute_path ): string { - $content_dir = WP_CONTENT_DIR; + $absolute_path = wp_normalize_path( $absolute_path ); + $content_dir = dirname( agentic_admin_plugins_dir() ); if ( str_starts_with( $absolute_path, $content_dir ) ) { return substr( $absolute_path, strlen( $content_dir ) + 1 ); diff --git a/includes/abilities/security/uploads-scan.php b/includes/abilities/security/uploads-scan.php index 87ac99e..52e79af 100644 --- a/includes/abilities/security/uploads-scan.php +++ b/includes/abilities/security/uploads-scan.php @@ -154,10 +154,17 @@ function agentic_admin_execute_uploads_scan( array $input = array() ): array { ); } - // 2. Scan WordPress root for unexpected PHP files. + // The site root (document root) is get_home_path(). It can differ from + // the WordPress directory on subdirectory installs. + if ( ! function_exists( 'get_home_path' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + $site_root = untrailingslashit( wp_normalize_path( get_home_path() ) ); + + // 2. Scan the site root for unexpected PHP files. // Core files are already covered by verify-core-checksums. Here we look for // non-wp-* PHP files that attackers drop at the root (e.g., db.php, x.php, shell.php). - $root_dir = untrailingslashit( ABSPATH ); + $root_dir = $site_root; if ( is_dir( $root_dir ) ) { $areas_scanned[] = 'root'; agentic_admin_scan_root_for_dangerous_files( @@ -172,12 +179,8 @@ function agentic_admin_execute_uploads_scan( array $input = array() ): array { // 3. Scan .well-known directory (recursive). // Attackers hide backdoors in .well-known/ because admins rarely check it // and some security scanners skip dotfiles/dotdirs. - // .well-known lives at the site root (document root), which is get_home_path() - // and can differ from ABSPATH on subdirectory installs. - if ( ! function_exists( 'get_home_path' ) ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - $well_known_dir = trailingslashit( get_home_path() ) . '.well-known'; + // .well-known lives at the site root. + $well_known_dir = $site_root . '/.well-known'; if ( is_dir( $well_known_dir ) ) { $areas_scanned[] = '.well-known'; agentic_admin_scan_directory_for_dangerous_files( @@ -332,7 +335,7 @@ function agentic_admin_scan_directory_for_dangerous_files( * since those are covered by verify-core-checksums. Flags anything else * with a dangerous extension. * - * @param string $root_dir Absolute path to ABSPATH. + * @param string $root_dir Absolute path to the site root. * @param array $dangerous_extensions Extensions to flag. * @param int $total_files Running count (by reference). * @param array $suspicious_files Running list (by reference). diff --git a/includes/abilities/security/verify-core-checksums.php b/includes/abilities/security/verify-core-checksums.php index f3ee414..76d7379 100644 --- a/includes/abilities/security/verify-core-checksums.php +++ b/includes/abilities/security/verify-core-checksums.php @@ -139,10 +139,10 @@ function agentic_admin_execute_verify_core_checksums( array $input = array() ): // Build a set of known core files for extra-file detection. $known_files = array(); - // Derive the content directory prefix relative to ABSPATH. - // WP_CONTENT_DIR is the absolute path (e.g., /var/www/html/wp-content or /var/www/html/content). - // We need the relative prefix to match against checksums entries. - $content_dir_prefix = trailingslashit( substr( WP_CONTENT_DIR, strlen( ABSPATH ) ) ); + // The checksums API lists content files under their location in the + // release package ("wp-content/..."), whatever the site's own content + // directory is, so skip that literal prefix. + $content_dir_prefix = 'wp-content/'; foreach ( $checksums as $file => $expected_md5 ) { // Skip content directory files — those are user-managed. diff --git a/includes/abilities/security/verify-plugin-checksums.php b/includes/abilities/security/verify-plugin-checksums.php index 4cae129..6400d04 100644 --- a/includes/abilities/security/verify-plugin-checksums.php +++ b/includes/abilities/security/verify-plugin-checksums.php @@ -132,10 +132,10 @@ function agentic_admin_execute_verify_plugin_checksums( array $input = array() ) } // Verify files against checksums. - $plugin_dir = WP_PLUGIN_DIR . '/' . dirname( $plugin_file ); - $is_single = ! str_contains( $plugin_file, '/' ); - $base_dir = $is_single ? WP_PLUGIN_DIR : $plugin_dir; - $issues = array(); + $plugins_dir = agentic_admin_plugins_dir(); + $is_single = ! str_contains( $plugin_file, '/' ); + $base_dir = $is_single ? $plugins_dir : $plugins_dir . '/' . dirname( $plugin_file ); + $issues = array(); foreach ( $checksums as $file => $hashes ) { $file_path = $base_dir . '/' . $file; diff --git a/includes/abilities/shared/diff-helpers.php b/includes/abilities/shared/diff-helpers.php index a61dd14..0ea5ba5 100644 --- a/includes/abilities/shared/diff-helpers.php +++ b/includes/abilities/shared/diff-helpers.php @@ -57,39 +57,11 @@ function agentic_admin_get_remote_file_diff( string $original_url, string $file_ * @return string Unified diff output. */ function agentic_admin_generate_unified_diff( array $old_lines, array $new_lines, string $old_label, string $new_label ): string { - // Use WordPress built-in Text_Diff if available. - if ( ! class_exists( 'Text_Diff', false ) ) { - $diff_file = ABSPATH . WPINC . '/Text/Diff.php'; - if ( file_exists( $diff_file ) ) { - require_once $diff_file; - } - } - - if ( ! class_exists( 'Text_Diff_Renderer_unified', false ) ) { - $renderer_file = ABSPATH . WPINC . '/Text/Diff/Renderer/unified.php'; - if ( file_exists( $renderer_file ) ) { - require_once $renderer_file; - } - } - - if ( class_exists( 'Text_Diff' ) && class_exists( 'Text_Diff_Renderer_unified' ) ) { - $diff = new \Text_Diff( 'auto', array( $old_lines, $new_lines ) ); - $renderer = new \Text_Diff_Renderer_unified(); - $output = $renderer->render( $diff ); - - if ( empty( $output ) ) { - return ''; - } - - return "--- {$old_label}\n+++ {$new_label}\n{$output}"; - } - - // Fallback: simple line-by-line comparison. return agentic_admin_simple_diff( $old_lines, $new_lines, $old_label, $new_label ); } /** - * Simple fallback diff when Text_Diff is not available. + * Simple line-by-line diff. * * @param array $old_lines Lines from the original file. * @param array $new_lines Lines from the modified file. diff --git a/includes/abilities/shared/plugin-helpers.php b/includes/abilities/shared/plugin-helpers.php index fdd59a0..4788daf 100644 --- a/includes/abilities/shared/plugin-helpers.php +++ b/includes/abilities/shared/plugin-helpers.php @@ -12,6 +12,29 @@ exit; } +/** + * Absolute path of the plugins directory, without a trailing slash. + * + * Derived from this plugin's own location (plugin_dir_path( __FILE__ ), + * saved in AGENTIC_ADMIN_PLUGIN_DIR), which sits directly inside it. + * + * @return string + */ +function agentic_admin_plugins_dir(): string { + return wp_normalize_path( dirname( AGENTIC_ADMIN_PLUGIN_DIR ) ); +} + +/** + * Absolute path of the must-use plugins directory, or '' when no + * must-use plugins are installed. + * + * @return string + */ +function agentic_admin_mu_plugins_dir(): string { + $mu_plugins = wp_get_mu_plugins(); + return empty( $mu_plugins ) ? '' : wp_normalize_path( dirname( $mu_plugins[0] ) ); +} + /** * Get all installed plugins with their status. * diff --git a/includes/abilities/wp-api-extract.php b/includes/abilities/wp-api-extract.php deleted file mode 100644 index 3b51fbd..0000000 --- a/includes/abilities/wp-api-extract.php +++ /dev/null @@ -1,300 +0,0 @@ - __( 'Extract WP API Signatures', 'agentic-admin' ), - 'description' => __( 'Extract WordPress core function signatures and docblocks for knowledge base indexing.', 'agentic-admin' ), - 'category' => 'sre-tools', - 'input_schema' => array( - 'type' => 'object', - 'default' => array(), - 'properties' => array(), - 'additionalProperties' => false, - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'chunks' => array( - 'type' => 'array', - 'description' => __( 'API signature chunks.', 'agentic-admin' ), - ), - 'total_files' => array( - 'type' => 'integer', - 'description' => __( 'Total files scanned.', 'agentic-admin' ), - ), - ), - ), - 'execute_callback' => 'agentic_admin_execute_wp_api_extract', - 'permission_callback' => function () { - return current_user_can( 'manage_options' ); - }, - 'meta' => array( - 'show_in_rest' => true, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - ), - // JS configuration for chat interface. - array( - 'keywords' => array( 'wp api', 'wordpress functions', 'core api', 'extract api' ), - 'initialMessage' => __( 'Extracting WordPress API signatures...', 'agentic-admin' ), - ) - ); -} - -/** - * Collect PHP files from wp-includes/ (skipping non-PHP subdirectories). - * - * @return string[] Array of absolute file paths. - */ -function agentic_admin_collect_wp_includes_files(): array { - $wp_includes = ABSPATH . WPINC . '/'; - - if ( ! is_dir( $wp_includes ) ) { - return array(); - } - - // Skip directories that don't contain PHP API functions. - $skip_dirs = array( 'blocks', 'js', 'css', 'fonts', 'images', 'ID3', 'sodium_compat', 'Requests', 'SimplePie', 'Text' ); - - $files = array(); - $iterator = new DirectoryIterator( $wp_includes ); - - foreach ( $iterator as $item ) { - if ( $item->isDot() ) { - continue; - } - - // Skip subdirectories (only scan top-level wp-includes/ PHP files). - if ( $item->isDir() ) { - $name = $item->getFilename(); - - if ( in_array( $name, $skip_dirs, true ) ) { - continue; - } - - // Scan specific subdirectories that have useful API functions. - $sub_files = agentic_admin_scan_wp_includes_subdir( $item->getPathname() ); - $files = array_merge( $files, $sub_files ); - continue; - } - - if ( ! $item->isFile() ) { - continue; - } - - // Only PHP files. - if ( 'php' !== strtolower( $item->getExtension() ) ) { - continue; - } - - // Skip very large files (> 200KB). - if ( $item->getSize() > 200 * 1024 ) { - continue; - } - - $files[] = $item->getPathname(); - } - - sort( $files ); - return $files; -} - -/** - * Scan a wp-includes subdirectory for PHP files (non-recursive). - * - * @param string $dir Directory path. - * @return string[] Array of file paths. - */ -function agentic_admin_scan_wp_includes_subdir( string $dir ): array { - $files = array(); - $iterator = new DirectoryIterator( $dir ); - - foreach ( $iterator as $item ) { - if ( $item->isDot() || ! $item->isFile() ) { - continue; - } - - if ( 'php' !== strtolower( $item->getExtension() ) ) { - continue; - } - - if ( $item->getSize() > 200 * 1024 ) { - continue; - } - - $files[] = $item->getPathname(); - } - - return $files; -} - -/** - * Extract function signatures and docblocks from a PHP file. - * - * Captures docblock + function signature line only (not the body). - * Groups ~5 related functions per chunk. - * - * @param string $file_path Absolute path to the PHP file. - * @param string $relative_path Relative path for chunk identification. - * @return array Array of signature entries. - */ -function agentic_admin_extract_signatures( string $file_path, string $relative_path ): array { - global $wp_filesystem; - - if ( ! $wp_filesystem ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - WP_Filesystem(); - } - - $content = $wp_filesystem->get_contents( $file_path ); - - if ( false === $content ) { - return array(); - } - - $signatures = array(); - - // Match docblock + function signature (not body). - // Pattern: optional /** ... */ followed by function declaration. - $pattern = '/ - (\/\*\*[\s\S]*?\*\/\s*)? # Optional docblock - ^[ \t]*(?:(?:abstract|static|final|public|protected|private)\s+)* # Optional modifiers - function\s+(\w+)\s* # Function name - \(([^)]*)\) # Parameters - /mx'; - - if ( preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER ) ) { - foreach ( $matches as $match ) { - $docblock = isset( $match[1] ) ? trim( $match[1] ) : ''; - $func_name = $match[2]; - $params = trim( $match[3] ); - - // Skip internal/private functions (prefixed with _). - if ( str_starts_with( $func_name, '__' ) && 'construct' !== substr( $func_name, 2 ) ) { - continue; - } - - // Build concise signature. - $sig = "function {$func_name}( {$params} )"; - - $entry = ''; - if ( $docblock ) { - // Trim docblock to description + @param + @return only. - $entry = agentic_admin_trim_docblock( $docblock ) . "\n"; - } - $entry .= $sig; - - $signatures[] = $entry; - } - } - - return $signatures; -} - -/** - * Trim a docblock to keep only description, @param, and @return lines. - * - * @param string $docblock Full docblock string. - * @return string Trimmed docblock. - */ -function agentic_admin_trim_docblock( string $docblock ): string { - $lines = explode( "\n", $docblock ); - $kept = array(); - $keeping = true; - - foreach ( $lines as $line ) { - $trimmed = trim( $line, " \t*/" ); - - // Keep the opening. - if ( str_starts_with( trim( $line ), '/**' ) ) { - $kept[] = '/**'; - continue; - } - - // Keep description lines (before first @tag). - if ( $keeping && ! str_starts_with( $trimmed, '@' ) ) { - if ( '' !== $trimmed ) { - $kept[] = ' * ' . $trimmed; - } - continue; - } - - // Once we hit tags, only keep @param, @return, @since. - if ( str_starts_with( $trimmed, '@' ) ) { - $keeping = false; - if ( preg_match( '/^@(param|return|since)\b/', $trimmed ) ) { - $kept[] = ' * ' . $trimmed; - } - } - } - - $kept[] = ' */'; - return implode( "\n", $kept ); -} - -/** - * Execute the wp-api-extract ability. - * - * @param array $input Input parameters (unused). - * @return array - */ -function agentic_admin_execute_wp_api_extract( array $input = array() ): array { - $files = agentic_admin_collect_wp_includes_files(); - $total_files = count( $files ); - $chunks = array(); - $wp_includes = ABSPATH . WPINC . '/'; - - foreach ( $files as $file_path ) { - $relative_path = str_replace( $wp_includes, '', $file_path ); - $signatures = agentic_admin_extract_signatures( $file_path, $relative_path ); - - if ( empty( $signatures ) ) { - continue; - } - - // Group ~5 signatures per chunk. - $groups = array_chunk( $signatures, 5 ); - - foreach ( $groups as $group_idx => $group ) { - $chunks[] = array( - 'path' => 'wp-api://' . $relative_path, - 'start_line' => $group_idx * 5 + 1, - 'end_line' => $group_idx * 5 + count( $group ), - 'content' => implode( "\n\n", $group ), - 'type' => 'api', - ); - } - } - - return array( - 'success' => true, - 'chunks' => $chunks, - 'total_files' => $total_files, - ); -} diff --git a/includes/abilities/wp-config-list.php b/includes/abilities/wp-config-list.php new file mode 100644 index 0000000..3d66df5 --- /dev/null +++ b/includes/abilities/wp-config-list.php @@ -0,0 +1,254 @@ + __( 'List wp-config constants', 'agentic-admin' ), + 'description' => __( 'List the constants defined in wp-config.php. Credentials, authentication keys, and salts are never returned.', 'agentic-admin' ), + 'category' => 'sre-tools', + 'input_schema' => array( + 'type' => 'object', + 'default' => array(), + 'properties' => array(), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the operation was successful.', 'agentic-admin' ), + ), + 'message' => array( + 'type' => 'string', + 'description' => __( 'Status message.', 'agentic-admin' ), + ), + 'constants' => array( + 'type' => 'array', + 'description' => __( 'Constants as name/value pairs. Sensitive values are replaced by [REDACTED].', 'agentic-admin' ), + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Number of constants found.', 'agentic-admin' ), + ), + 'was_redacted' => array( + 'type' => 'boolean', + 'description' => __( 'Whether any sensitive constants were withheld.', 'agentic-admin' ), + ), + ), + ), + 'execute_callback' => 'agentic_admin_execute_wp_config_list', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + ), + // JS configuration for chat interface. + array( + 'keywords' => array( 'constants', 'defined', 'define', 'wp-config', 'configuration', 'WP_DEBUG', 'config constants', 'wp-config constants' ), + 'initialMessage' => __( "I'll list the wp-config.php constants...", 'agentic-admin' ), + ) + ); +} + +/** + * Execute the wp-config-list ability. + * + * @param array $input Input parameters (unused). + * @return array + */ +function agentic_admin_execute_wp_config_list( array $input = array() ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Abilities API signature. + $config_file = agentic_admin_locate_wp_config(); + + if ( '' === $config_file ) { + return array( + 'success' => false, + 'message' => __( 'Could not locate wp-config.php.', 'agentic-admin' ), + ); + } + + $names = agentic_admin_wp_config_define_names( $config_file ); + $constants = array(); + $was_redacted = false; + + foreach ( $names as $name ) { + if ( agentic_admin_is_sensitive_constant( $name ) ) { + $value = '[REDACTED]'; + $was_redacted = true; + } elseif ( defined( $name ) ) { + $value = agentic_admin_format_constant_value( constant( $name ) ); + } else { + $value = __( '(not defined at runtime)', 'agentic-admin' ); + } + + $constants[] = array( + 'name' => $name, + 'value' => $value, + ); + } + + return array( + 'success' => true, + 'message' => sprintf( + /* translators: %d: number of constants */ + _n( '%d constant defined in wp-config.php.', '%d constants defined in wp-config.php.', count( $constants ), 'agentic-admin' ), + count( $constants ) + ), + 'constants' => $constants, + 'total' => count( $constants ), + 'was_redacted' => $was_redacted, + ); +} + +/** + * Locate wp-config.php the same way wp-load.php does: in the WordPress + * directory, or one level above it when that directory is not itself + * another WordPress install. + * + * @return string Absolute path, or an empty string when not found. + */ +function agentic_admin_locate_wp_config(): string { + $wp_dir = untrailingslashit( wp_normalize_path( ABSPATH ) ); + + if ( file_exists( $wp_dir . '/wp-config.php' ) ) { + return $wp_dir . '/wp-config.php'; + } + + $parent = dirname( $wp_dir ); + if ( file_exists( $parent . '/wp-config.php' ) && ! file_exists( $parent . '/wp-settings.php' ) ) { + return $parent . '/wp-config.php'; + } + + return ''; +} + +/** + * Collect the constant names passed to define() in a PHP file. + * + * Uses the PHP tokenizer so only literal names are collected. Values in the + * file are never extracted. + * + * @param string $file Absolute path to the PHP file. + * @return string[] Unique constant names in file order. + */ +function agentic_admin_wp_config_define_names( string $file ): array { + $source = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, tokenized only. + if ( false === $source ) { + return array(); + } + + $tokens = token_get_all( $source ); + unset( $source ); + + $names = array(); + $count = count( $tokens ); + + for ( $i = 0; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + if ( ! is_array( $token ) || ! in_array( $token[0], array( T_STRING, T_NAME_FULLY_QUALIFIED ), true ) || 'define' !== strtolower( ltrim( $token[1], '\\' ) ) ) { + continue; + } + + // Next significant tokens must be "(" then a literal string name. + $j = agentic_admin_next_significant_token( $tokens, $i + 1 ); + if ( null === $j || '(' !== $tokens[ $j ] ) { + continue; + } + $j = agentic_admin_next_significant_token( $tokens, $j + 1 ); + if ( null === $j || ! is_array( $tokens[ $j ] ) || T_CONSTANT_ENCAPSED_STRING !== $tokens[ $j ][0] ) { + continue; + } + + $name = substr( $tokens[ $j ][1], 1, -1 ); + if ( preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $name ) ) { + $names[ $name ] = true; + } + } + + return array_keys( $names ); +} + +/** + * Find the index of the next token that is not whitespace or a comment. + * + * @param array $tokens Token list from token_get_all(). + * @param int $start Index to start from. + * @return int|null Token index, or null at end of input. + */ +function agentic_admin_next_significant_token( array $tokens, int $start ): ?int { + $count = count( $tokens ); + for ( $i = $start; $i < $count; $i++ ) { + if ( is_array( $tokens[ $i ] ) && in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { + continue; + } + return $i; + } + return null; +} + +/** + * Whether a constant holds a credential, key, salt, or similar secret. + * + * @param string $name Constant name. + * @return bool + */ +function agentic_admin_is_sensitive_constant( string $name ): bool { + $upper = strtoupper( $name ); + + if ( in_array( $upper, array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST' ), true ) ) { + return true; + } + + return (bool) preg_match( '/(KEY|SALT|SECRET|TOKEN|PASS|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE|LICENSE)/', $upper ); +} + +/** + * Format a constant's runtime value for display. + * + * @param mixed $value Constant value. + * @return string + */ +function agentic_admin_format_constant_value( $value ): string { + if ( is_bool( $value ) ) { + return $value ? 'true' : 'false'; + } + if ( null === $value ) { + return 'null'; + } + if ( is_scalar( $value ) ) { + return (string) $value; + } + return (string) wp_json_encode( $value ); +} diff --git a/includes/class-connectors.php b/includes/class-connectors.php index 9960acf..f5358b8 100644 --- a/includes/class-connectors.php +++ b/includes/class-connectors.php @@ -134,12 +134,7 @@ public static function list_connectors(): \WP_REST_Response { continue; } - // "Connected" = the connector's API credential is present. This is - // deterministic. The AI Client registry's isProviderConfigured() - // proved flaky here (it can report a configured provider as not - // configured between calls), which made the connector list flap and - // show "no connectors configured" even when keys were set. - $is_connected = self::is_connector_configured( $data ); + $is_connected = self::is_connector_configured( $id ); $models = array(); if ( $is_connected && null !== $ai_registry ) { @@ -173,34 +168,40 @@ public static function list_connectors(): \WP_REST_Response { } /** - * Whether a connector has its API credential configured. + * Whether a connector's provider is configured in the AI Client. * - * Deterministic check against the connector's declared authentication - * sources (a defined constant, an environment variable, or a saved - * option), in that order. Used instead of the AI Client registry's - * isProviderConfigured(), which proved non-deterministic. + * Uses the same check as the core Connectors screen + * (hasProvider() && isProviderConfigured()), so the plugin never reads + * API keys itself. isProviderConfigured() can briefly report a configured + * provider as unconfigured, which made the connector list flap, so a + * positive result is remembered for a few minutes. Only the boolean is + * cached, never a credential. * - * @param array $data Connector definition from wp_get_connectors(). - * @return bool True when a non-empty credential is found. + * @param string $connector_id Connector / provider ID. + * @return bool True when the provider is configured. */ - private static function is_connector_configured( array $data ): bool { - $auth = isset( $data['authentication'] ) && is_array( $data['authentication'] ) - ? $data['authentication'] - : array(); + private static function is_connector_configured( string $connector_id ): bool { + if ( '' === $connector_id || ! class_exists( '\\WordPress\\AiClient\\AiClient' ) ) { + return false; + } - if ( ! empty( $auth['constant_name'] ) && defined( $auth['constant_name'] ) && constant( $auth['constant_name'] ) ) { + $cache_key = 'agentic_admin_conn_ok_' . md5( $connector_id ); + if ( get_transient( $cache_key ) ) { return true; } - if ( ! empty( $auth['env_var_name'] ) && getenv( $auth['env_var_name'] ) ) { - return true; + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $configured = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( \Exception $e ) { + $configured = false; } - if ( ! empty( $auth['setting_name'] ) && ! empty( \get_option( $auth['setting_name'] ) ) ) { - return true; + if ( $configured ) { + set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS ); } - return false; + return $configured; } /** @@ -276,8 +277,7 @@ public static function chat_completion( \WP_REST_Request $request ) { array( 'status' => 404 ) ); } - $connectors_meta = function_exists( '\\wp_get_connectors' ) ? \wp_get_connectors() : array(); - if ( ! self::is_connector_configured( $connectors_meta[ $connector_id ] ?? array() ) ) { + if ( ! self::is_connector_configured( $connector_id ) ) { return new \WP_Error( 'agentic_admin_unconfigured_connector', sprintf( 'Connector "%s" is not configured (missing API key).', $connector_id ), diff --git a/includes/class-utils.php b/includes/class-utils.php index 428b89a..0a22699 100644 --- a/includes/class-utils.php +++ b/includes/class-utils.php @@ -89,11 +89,20 @@ public static function get_browser_requirements(): array { * @return string|false */ public static function get_debug_log_path() { + // When WP_DEBUG_LOG is on, wp_debug_mode() points PHP's error_log + // setting at the log file, custom path or default location alike. + if ( self::is_debug_log_enabled() ) { + $log_file = ini_get( 'error_log' ); + if ( is_string( $log_file ) && '' !== $log_file ) { + return $log_file; + } + } + if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) ) { return WP_DEBUG_LOG; } - return WP_CONTENT_DIR . '/debug.log'; + return false; } /** diff --git a/readme.txt b/readme.txt index f97526a..8a4a74f 100644 --- a/readme.txt +++ b/readme.txt @@ -41,7 +41,7 @@ Agentic Admin transforms your WordPress admin panel into an intelligent command 1. The Agentic Admin chat tab in wp-admin, mid-conversation. The model has just answered a question about installed plugins by calling the `plugin-list` tool locally — full ReAct trace (user question, thought process, tool call, answer) visible. 2. First-run model download in progress. The Qwen 3 1.7B weights (~1.2 GB) are fetched from the MLC-AI / HuggingFace CDN — once per browser, cancellable, cached for subsequent sessions. 3. The Abilities browser, listing every tool the assistant can call against the WordPress Abilities API on this site. -4. Settings panel. Build the local knowledge base, see detected GPU + VRAM, tune context-window size per model based on your hardware, toggle thinking mode, and switch between the local engine (WebLLM + WebGPU), a remote OpenAI-compatible endpoint, or the WordPress 7.0 Connector. +4. Settings panel. See detected GPU + VRAM, tune context-window size per model based on your hardware, toggle thinking mode, and switch between the local engine (WebLLM + WebGPU), a remote OpenAI-compatible endpoint, or the WordPress 7.0 Connector. 5. Multi-step workflow execution. "Do a performance check" is recognized as a 2-step workflow — the assistant runs `site-health` and `error-log-read` in sequence, then summarizes the environment (WP version, PHP, memory, debug mode, error log status) in one answer. 6. WordPress 7.0 AI Connector integration. The Connector tab picks up any AI provider registered via WP 7.0's built-in Connector API — Anthropic, Google, OpenAI, or any third-party `ai_provider` plugin — and uses it as the model backend with zero extra setup. @@ -51,14 +51,10 @@ This plugin runs AI locally in your browser by default, and your prompts and cha Separately from AI inference, some abilities query public data sources to do their job: a security scan checks your plugin versions against CVE databases, a checksum verification compares your files against WordPress.org, and a web search sends your query to a search engine. Every external request the plugin makes is listed here: -**Model weights CDN (MLC-AI / HuggingFace)** — Always for the local engine. -On first use the browser downloads the selected model (Qwen 3 1.7B by default, ~1.2 GB) from `https://huggingface.co/mlc-ai/` and `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/`. Only HTTP GET requests for static model files are made; no prompts, admin data, or telemetry are sent. Weights are cached in the browser; subsequent sessions are offline. -HuggingFace terms: https://huggingface.co/terms-of-service — Privacy: https://huggingface.co/privacy - -**Transformers.js library + embedding model (jsDelivr + Hugging Face)** — Only when the optional local knowledge base is enabled in settings. -This performs in-browser semantic embedding of your documentation and code so the assistant can answer from a local knowledge base; the computation runs entirely in your browser. The Transformers.js library is loaded from jsDelivr (`https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/`) and the embedding model (`Xenova/all-MiniLM-L6-v2`, ~23 MB, one-time) from Hugging Face. Both are static files cached in the browser after first use; no prompts or admin data are sent. -jsDelivr terms: https://www.jsdelivr.com/terms — Privacy: https://www.jsdelivr.com/privacy-policy-jsdelivr-net +**AI model download service (MLC-AI, hosted on Hugging Face and GitHub)** — Only when the local engine is used. +The local engine is the plugin's core service: it runs a language model in the administrator's browser. The model is not part of the plugin, because model files are over 1 GB and are published and versioned by the MLC-AI project. When the site owner selects a local model in the plugin's settings (Qwen 3 1.7B by default, ~1.2 GB, or Qwen 2.5 7B, ~4.5 GB), the administrator's browser downloads that model's weights from `https://huggingface.co/mlc-ai/` and its compiled model library from `https://raw.githubusercontent.com/mlc-ai/binary-mlc-llm-libs/`. The site owner's choice of engine and model determines what is downloaded. No account or API key is needed. Only HTTP GET requests for static files are made, directly from the browser (the WordPress server makes no requests for them), and no prompts, admin data, or telemetry are sent. Files are cached in the browser after the first download. Hugging Face terms: https://huggingface.co/terms-of-service — Privacy: https://huggingface.co/privacy +GitHub terms: https://docs.github.com/en/site-policy/github-terms/github-terms-of-service — Privacy: https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement **External LLM provider (user-configured)** — Only when you switch the engine from "Local" to "Remote" in settings. When enabled, chat messages, tool descriptions, and tool results are sent through this plugin's REST proxy (`/wp-json/agentic-admin/v1/llm-proxy/`) to the OpenAI-compatible endpoint URL you configure (e.g. Ollama, LM Studio, vLLM, OpenAI, Groq, Together). You choose the endpoint; the plugin does not preselect or default to any third-party provider. No data is sent until you save an endpoint and start a chat in Remote mode. @@ -89,12 +85,12 @@ Agentic Admin is fully open source under GPL-2.0-or-later. The complete, human-r https://github.com/pluginslab/wp-agentic-admin -The files under `build-extensions/` are generated from the sources in `src/` with @wordpress/scripts (webpack). They contain only JavaScript and CSS: the bundles (`index.js`, `sw.js`, `indexing-worker.js`, and code-split chunks) and the stylesheets. No WebAssembly, binaries, or other compiled artifacts are distributed with the plugin. To regenerate them from a checkout: +The files under `build-extensions/` are generated from the sources in `src/` with @wordpress/scripts (webpack). They contain only JavaScript and CSS: the bundles (`index.js`, `sw.js`, and code-split chunks) and the stylesheets. No WebAssembly, binaries, or other compiled artifacts are distributed with the plugin. To regenerate them from a checkout: 1. `npm install` 2. `npm run build` -There is no build step for the PHP. The WebLLM engine is bundled into the plugin from its npm package. The optional knowledge-base embedding library (Transformers.js) and all AI model weights are loaded at runtime from the providers documented under External services above; these are large provider-hosted files, not part of the plugin code. +There is no build step for the PHP. The WebLLM engine is bundled into the plugin from its npm package. The AI model weights and their compiled model libraries are loaded at runtime from the provider documented under External services above; these are large provider-hosted model files (over 1 GB), not part of the plugin code. == Changelog == @@ -112,13 +108,16 @@ There is no build step for the PHP. The WebLLM engine is bundled into the plugin * Fixed: AI model no longer preloads on every wp-admin page, deferred until the user opens the sidebar for the first time (#116). * Improved: post-tool summarization is brief (no re-listing items the user already sees in the tool result UI). * Improved: ChatInput keyboard handling simplified (Space inserts a space, no push-to-talk hijacking). -* Improved: KB embedding moved to a Web Worker with persistent progress across tab switches. -* Pinned: Transformers.js CDN URL to @3.8.1 (was floating @3 range), privacy-first plugin shouldn't depend on a CDN range that can ship new code without a deliberate bump. -* Removed: the voy-search dependency and the WebAssembly module it distributed. Vector search is now plain JavaScript (exhaustive cosine over L2-normalised embeddings), so the plugin ships no compiled binaries at all and every distributed file has readable source in the repository. -* Fixed: re-running the knowledge base index no longer leaves vectors and chunk metadata misaligned, which could return the wrong code chunk for a query. +* Removed: the local knowledge base (in-browser embeddings, vector store, and the codebase-index, code-search, schema-extract, wp-api-extract, docs-extract, and codebase-extract abilities). The plugin no longer loads Transformers.js from a CDN or ships any WebAssembly module. +* Security: read-file now refuses files that can hold credentials, keys, or salts (wp-config.php, .env, private keys, database dumps, and any file defining the auth keys, salts, or DB password) instead of relying on redaction. +* Security: wp-config-list now has its own PHP backend that reads constant names with the PHP tokenizer and never reads the values of credentials, keys, or salts. +* Security: removed the security-scan salts check, which read the auth salt and could never fail. +* Changed: AI Connectors are detected through the AI Client registry, as on the core Connectors screen, instead of reading API-key options. +* Fixed: plugin, must-use plugin, and content paths are derived from the plugin's own location and WordPress functions instead of WP_PLUGIN_DIR, WPMU_PLUGIN_DIR, and WP_CONTENT_DIR. Core checksum verification no longer miscounts files when the content directory is moved. The debug log path follows WordPress's own error_log setting. +* Fixed: read-file and uploads-scan resolve the site root with get_home_path(), so subdirectory installs work. * Fixed: `.well-known` scanning resolves via get_home_path() instead of ABSPATH, so subdirectory installs scan the real site root. * Removed: 7 stale tab references and 6+ stale docs files (FEEDBACK-DEV.md). -* Tests: 96 unit tests passing, plus the new manifest test suite (7 cases), index test suite (6 cases), knowledge-base test suite (16 cases), and react-agent regression tests (3 cases for the per-call state cleanup fix). +* Tests: 96 unit tests passing, plus the new manifest test suite (7 cases), index test suite (6 cases), and react-agent regression tests (3 cases for the per-call state cleanup fix). = 0.10.0 = * CloudFest Hackathon 2026 release: 78 PRs merged, 42+ abilities shipped by 11 contributors diff --git a/src/extensions/abilities/__tests__/manifest.test.js b/src/extensions/abilities/__tests__/manifest.test.js index e49a11c..cb6cc74 100644 --- a/src/extensions/abilities/__tests__/manifest.test.js +++ b/src/extensions/abilities/__tests__/manifest.test.js @@ -76,8 +76,6 @@ describe( 'abilities manifest', () => { 'current-user-role', 'core-site-info', 'core-environment-info', - 'codebase-index', - 'code-search', 'discover-plugin-abilities', 'run-plugin-ability', ]; @@ -108,9 +106,6 @@ describe( 'abilities manifest', () => { 'current-user-role', 'core-site-info', 'core-environment-info', - 'codebase-index', - 'code-search', - 'wp-config-list', // also LOCAL_ONLY ].sort() ); } ); diff --git a/src/extensions/abilities/code-search.js b/src/extensions/abilities/code-search.js deleted file mode 100644 index 61d3a5a..0000000 --- a/src/extensions/abilities/code-search.js +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Code Search Ability - * - * Searches the local knowledge base using semantic vector search. - * The knowledge base includes site code, database schemas, - * WordPress API signatures, and reference documentation. - * - * @see src/extensions/services/vector-store.js for the vector store service - * @see src/extensions/abilities/codebase-index.js for the indexing ability - */ - -import { registerAbility } from '../services/agentic-abilities-api'; -import vectorStore from '../services/vector-store'; -import { createLogger } from '../utils/logger'; - -const log = createLogger( 'CodeSearch' ); - -/** - * Register the code-search ability with the chat system. - */ -export function registerCodeSearch() { - registerAbility( 'agentic-admin/code-search', { - label: 'Search knowledge base', - description: - 'Search the knowledge base for code, database schema, WordPress API docs, or reference information. Use when users ask to find, search, or look up anything about the site or WordPress.', - - keywords: [ - 'search code', - 'find function', - 'where is', - 'codebase', - 'find class', - 'find code', - 'code search', - 'search codebase', - 'database', - 'schema', - 'wordpress', - 'documentation', - 'how does', - 'what is', - ], - - initialMessage: 'Searching your knowledge base...', - - parseIntent: ( message ) => { - return { query: message }; - }, - - summarize: ( result ) => { - if ( result.error ) { - return result.error; - } - - if ( ! result.matches || result.matches.length === 0 ) { - return 'No matching results found in the knowledge base.'; - } - - let summary = `Found **${ result.matches.length }** relevant results:\n\n`; - - result.matches.forEach( ( match, i ) => { - summary += `**${ i + 1 }. ${ match.path }** (lines ${ - match.start_line - }-${ match.end_line })\n`; - summary += '```\n'; - // Truncate for display. - const preview = - match.content.length > 500 - ? match.content.slice( 0, 500 ) + '\n...' - : match.content; - summary += preview + '\n'; - summary += '```\n\n'; - } ); - - return summary; - }, - - interpretResult: ( result ) => { - if ( result.error ) { - return result.error; - } - - if ( ! result.matches || result.matches.length === 0 ) { - return 'No matching results found in the knowledge base.'; - } - - // Strict budget: max 2 snippets, ~400 chars each, total under 800 chars. - const snippets = result.matches.slice( 0, 2 ); - const parts = snippets.map( ( match ) => { - const content = match.content.slice( 0, 400 ); - return `${ match.path }:${ match.start_line }: ${ content }`; - } ); - - return parts.join( '\n' ); - }, - - execute: async ( params ) => { - try { - // Initialize vector store (restores index from IndexedDB). - await vectorStore.init(); - - if ( ! vectorStore.isReady() ) { - return { - error: "Knowledge base not indexed yet. Run 'index the codebase' first.", - }; - } - - // Extract query from params. - const query = params.query || params.userMessage || 'code'; - - log.info( `Query: "${ query }"` ); - log.info( `Index has ${ vectorStore.getChunkCount() } chunks` ); - - const matches = await vectorStore.search( query, 3 ); - - matches.forEach( ( match, i ) => { - log.info( - `Result ${ i + 1 }: ${ match.path }:${ - match.start_line - }-${ match.end_line } (score: ${ match.score }) type: ${ - match.type - }` - ); - log.info( - ` Preview: ${ match.content.slice( 0, 150 ) }...` - ); - } ); - - return { - matches, - total: matches.length, - indexed_chunks: vectorStore.getChunkCount(), - }; - } catch ( err ) { - return { - error: `Search failed: ${ err.message }`, - }; - } - }, - - requiresConfirmation: false, - } ); -} - -export default registerCodeSearch; diff --git a/src/extensions/abilities/codebase-index.js b/src/extensions/abilities/codebase-index.js deleted file mode 100644 index 4e90fe8..0000000 --- a/src/extensions/abilities/codebase-index.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Codebase Index Ability - * - * Thin wrapper that delegates to the Knowledge Base service in Settings. - * When invoked from chat, runs the full indexing pipeline. - * Users can also build the index from the Settings tab. - * - * @see src/extensions/services/knowledge-base.js for the indexing service - * @see src/extensions/services/vector-store.js for the vector store - */ - -import { registerAbility } from '../services/agentic-abilities-api'; -import { buildIndex, getKBStatus } from '../services/knowledge-base'; - -/** - * Register the codebase-index ability with the chat system. - */ -export function registerCodebaseIndex() { - registerAbility( 'agentic-admin/codebase-index', { - label: 'Build knowledge base index', - description: - 'Build a search index from site code, database schema, WordPress API, and reference docs. Only use when the user explicitly says "index", "reindex", or "build knowledge base". Do NOT use for searching.', - - keywords: [ - 'index codebase', - 'reindex', - 'build index', - 'scan code', - 'build knowledge base', - ], - - initialMessage: - 'Building your knowledge base... This may take 1-3 minutes.', - - requiresConfirmation: true, - confirmationMessage: - 'This will build a search index from your site code, database schema, WordPress API signatures, and reference docs. This may take 1-3 minutes. Continue?', - - summarize: ( result ) => { - if ( result.error ) { - return `Indexing failed: ${ result.error }`; - } - let summary = `Indexed **${ result.totalChunks }** chunks from **${ result.codeFiles }** code files.`; - if ( result.schemaTables ) { - summary += ` Includes **${ result.schemaTables }** database table schemas.`; - } - if ( result.apiChunks ) { - summary += ` Includes **${ result.apiChunks }** WP API signature chunks.`; - } - if ( result.docsChunks ) { - summary += ` Includes **${ result.docsChunks }** reference doc chunks.`; - } - summary += ' Your knowledge base is ready to search.'; - return summary; - }, - - interpretResult: ( result ) => { - if ( result.error ) { - return `Indexing failed: ${ result.error }`; - } - let summary = `Indexed ${ result.totalChunks } chunks from ${ result.codeFiles } files.`; - if ( result.schemaTables ) { - summary += ` ${ result.schemaTables } DB schemas.`; - } - if ( result.apiChunks ) { - summary += ` ${ result.apiChunks } API signatures.`; - } - if ( result.docsChunks ) { - summary += ` ${ result.docsChunks } doc sections.`; - } - summary += ' Knowledge base is now searchable.'; - return summary; - }, - - execute: async () => { - try { - // Check if already indexed recently (< 5 min ago). - const existing = getKBStatus(); - if ( - existing && - Date.now() - existing.lastIndexed < 5 * 60 * 1000 - ) { - return { - ...existing, - note: 'Knowledge base was already built recently. You can rebuild from Settings if needed.', - }; - } - - const status = await buildIndex(); - return status; - } catch ( err ) { - return { - error: err.message, - }; - } - }, - } ); -} - -export default registerCodebaseIndex; diff --git a/src/extensions/abilities/manifest.js b/src/extensions/abilities/manifest.js index 620cf79..ef0e94d 100644 --- a/src/extensions/abilities/manifest.js +++ b/src/extensions/abilities/manifest.js @@ -41,8 +41,6 @@ import { registerVerifyPluginChecksums } from './verify-plugin-checksums'; import { registerDatabaseCheck } from './database-check'; import { registerFileScan } from './file-scan'; import { registerRoleCapabilitiesCheck } from './role-capabilities-check'; -import { registerCodebaseIndex } from './codebase-index'; -import { registerCodeSearch } from './code-search'; // Local-only abilities (hidden from LLM when external provider is active). import { registerReadFile } from './read-file'; @@ -100,8 +98,6 @@ export const REGISTRARS = { // CORE — knowledge. 'web-search': registerWebSearch, - 'codebase-index': registerCodebaseIndex, - 'code-search': registerCodeSearch, // LOCAL_ONLY — sensitive abilities. 'read-file': registerReadFile, @@ -129,7 +125,4 @@ export const JS_ONLY_ABILITIES = new Set( [ 'current-user-role', 'core-site-info', 'core-environment-info', - 'codebase-index', - 'code-search', - 'wp-config-list', ] ); diff --git a/src/extensions/abilities/security-scan.js b/src/extensions/abilities/security-scan.js index 3631286..d1dde98 100644 --- a/src/extensions/abilities/security-scan.js +++ b/src/extensions/abilities/security-scan.js @@ -18,7 +18,7 @@ export function registerSecurityScan() { registerAbility( 'agentic-admin/security-scan', { label: 'Run basic security scan', description: - 'Run basic WordPress security checks including debug mode, file permissions, salts, and version exposure. Use for security audits.', + 'Run basic WordPress security checks including debug mode, file permissions, and version exposure. Use for security audits.', keywords: [ 'security', diff --git a/src/extensions/abilities/wp-config-list.js b/src/extensions/abilities/wp-config-list.js index 6dc47a6..9828630 100644 --- a/src/extensions/abilities/wp-config-list.js +++ b/src/extensions/abilities/wp-config-list.js @@ -5,11 +5,10 @@ * ================= * Lists and categorizes all PHP constants defined in wp-config.php. * - * Delegates file reading to the read-file ability so that all path - * resolution, ABSPATH security checks, and sensitive-value redaction - * (DB_PASSWORD, auth keys, salts) are handled by the existing PHP backend. - * The JS layer then parses define() calls from the already-redacted content - * and groups them by purpose. + * The PHP backend tokenizes wp-config.php for constant names only and + * returns runtime values for non-sensitive constants. Credentials, auth + * keys, and salts come back as [REDACTED] without their value being read. + * The JS layer groups the constants by purpose. * * EXECUTE RETURNS: * { @@ -23,7 +22,7 @@ * Uses preferSummarize: true — summarize() renders a grouped markdown list * directly, bypassing the LLM to avoid truncation of long constant tables. * - * @see includes/abilities/read-file.php — file reading and redaction backend + * @see includes/abilities/wp-config-list.php — PHP backend * @see docs/ABILITIES-GUIDE.md — registration API reference */ @@ -139,35 +138,6 @@ function getCategory( name ) { return 'custom'; } -/** - * Parse define() constants from PHP file content. - * - * Handles boolean, integer, float, null, and string (single- or double-quoted) - * values. Strings may contain [REDACTED] after server-side redaction. - * - * @param {string} content - PHP file content (already redacted server-side). - * @return {Array<{name: string, value: string, category: string}>} Parsed constants. - */ -function parseDefineConstants( content ) { - const constants = []; - const defineRe = - /define\s*\(\s*['"]([A-Z][A-Z0-9_]*)["']\s*,\s*(true|false|null|-?\d+(?:\.\d+)?|'[^']*'|"[^"]*")\s*\)/gi; - let match; - while ( ( match = defineRe.exec( content ) ) !== null ) { - const name = match[ 1 ]; - let value = match[ 2 ]; - // Strip surrounding quotes from string values. - if ( - ( value.startsWith( "'" ) && value.endsWith( "'" ) ) || - ( value.startsWith( '"' ) && value.endsWith( '"' ) ) - ) { - value = value.slice( 1, -1 ); - } - constants.push( { name, value, category: getCategory( name ) } ); - } - return constants; -} - /** * Format parsed constants as markdown grouped by category. * @@ -235,34 +205,36 @@ export function registerWpConfigList() { initialMessage: "I'll list the wp-config.php constants...", /** - * Read wp-config.php via the read-file ability and parse its constants. - * - * Delegates to read-file so that path resolution, ABSPATH validation, - * and sensitive-value redaction are handled by the existing PHP backend. + * Fetch the constants from the PHP backend and categorize them. * - * @return {Promise} Structured result with parsed constants. + * @return {Promise} Structured result with categorized constants. */ execute: async () => { - const fileResult = await executeAbility( - 'agentic-admin/read-file', - { file_path: 'wp-config.php', lines: 500 } + const result = await executeAbility( + 'agentic-admin/wp-config-list', + {} ); - if ( ! fileResult.success ) { + if ( ! result.success ) { return { success: false, - message: - fileResult.message || 'Could not read wp-config.php.', + message: result.message || 'Could not read wp-config.php.', }; } - const constants = parseDefineConstants( fileResult.content || '' ); + const constants = ( result.constants || [] ).map( + ( { name, value } ) => ( { + name, + value, + category: getCategory( name ), + } ) + ); return { success: true, constants, total: constants.length, - was_redacted: fileResult.was_redacted || false, + was_redacted: result.was_redacted || false, }; }, diff --git a/src/extensions/components/ChatInput.jsx b/src/extensions/components/ChatInput.jsx index 319ae1d..16a125e 100644 --- a/src/extensions/components/ChatInput.jsx +++ b/src/extensions/components/ChatInput.jsx @@ -5,7 +5,6 @@ */ import { useState, useRef, useEffect } from '@wordpress/element'; -import vectorStore from '../services/vector-store'; import { Button, Dropdown, @@ -15,7 +14,7 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, } from '@wordpress/components'; -import { plus, send, globe, search } from '@wordpress/icons'; +import { plus, send, globe } from '@wordpress/icons'; import ABILITY_BUNDLES from '../data/ability-bundles'; import pluginAbilitiesManager from '../services/plugin-abilities-manager'; import ModelStatusPill from './ModelStatusPill'; @@ -32,23 +31,9 @@ const ChatInput = ( { // via the "+" DropdownMenu. Clicking a row toggles its membership. const [ selectedBundleIds, setSelectedBundleIds ] = useState( [] ); const [ webSearchEnabled, setWebSearchEnabled ] = useState( false ); - const [ docSearchEnabled, setDocSearchEnabled ] = useState( false ); - const [ kbIndexReady, setKbIndexReady ] = useState( false ); const [ pluginBundles, setPluginBundles ] = useState( [] ); const textareaWrapperRef = useRef( null ); - useEffect( () => { - const checkIndex = async () => { - try { - await vectorStore.init(); - setKbIndexReady( vectorStore.isReady() ); - } catch { - setKbIndexReady( false ); - } - }; - checkIndex(); - }, [] ); - useEffect( () => { const refresh = () => { setPluginBundles( pluginAbilitiesManager.getPluginBundles() ); @@ -108,7 +93,6 @@ const ChatInput = ( { pluginNamespace: first?.pluginNamespace || null, pluginNamespaces: pluginNamespaces.length ? pluginNamespaces : null, webSearch: webSearchEnabled, - docSearch: docSearchEnabled, } ); setMessage( '' ); }; @@ -226,20 +210,6 @@ const ChatInput = ( { onClick={ () => setWebSearchEnabled( ! webSearchEnabled ) } disabled={ isDisabled } /> - - { kbStatus && ! kbBuilding && ( - - ) } - - - - - { kbStatus && ! kbBuilding && ( - - - { timeAgo( kbStatus.lastIndexed ) } - - - { kbStatus.totalChunks.toLocaleString() } - - - { kbStatus.codeFiles } - - - { kbStatus.schemaTables } - - - { kbStatus.apiChunks } chunks - - - { kbStatus.docsChunks } chunks - - - ) } - - { kbBuilding && kbProgress && ( - -

- { kbProgress.message } -

- -
- ) } - - { kbError && ( - setKbError( null ) } - > - { kbError } - - ) } - - - -

GPU Information

diff --git a/src/extensions/services/__tests__/knowledge-base.test.js b/src/extensions/services/__tests__/knowledge-base.test.js deleted file mode 100644 index 308c6ef..0000000 --- a/src/extensions/services/__tests__/knowledge-base.test.js +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Knowledge Base Tests - * - * Covers the singleton state model introduced in PR #187: - * - Initial state of the public getters - * - subscribe / unsubscribe and listener isolation - * - buildIndex deduplication when called concurrently - * - buildIndex success path: notifies, persists status, transitions _building - * - buildIndex error path: surfaces error via getError(), resets _building - * - clearIndex resets state and notifies - */ - -/* eslint-disable no-undef */ - -describe( 'knowledge-base', () => { - let kb; - let mockExecuteAbility; - let mockVectorStore; - let workerInstances; - let workerAutoComplete; - - /** - * Fake Worker that records construction and (optionally) auto-emits - * a 'complete' message on postMessage so buildIndex resolves without - * needing a real Web Worker runtime. - */ - class FakeWorker { - constructor( url ) { - this.url = url; - this.listeners = {}; - this.posted = []; - this.terminated = false; - workerInstances.push( this ); - } - addEventListener( type, fn ) { - ( this.listeners[ type ] = this.listeners[ type ] || [] ).push( - fn - ); - } - removeEventListener() {} - postMessage( msg ) { - this.posted.push( msg ); - if ( ! workerAutoComplete ) { - return; - } - // Resolve on a microtask so the caller has time to attach handlers. - queueMicrotask( () => { - this.emit( 'message', { - type: 'complete', - embeddings: [ { id: '0' } ], - chunkMetadata: [ - { - id: '0', - path: 'theme/style.php', - start_line: 1, - end_line: 10, - content: 'x', - type: 'code', - }, - ], - } ); - } ); - } - terminate() { - this.terminated = true; - } - emit( type, data ) { - for ( const fn of this.listeners[ type ] || [] ) { - fn( { data } ); - } - } - } - - beforeEach( () => { - jest.resetModules(); - workerInstances = []; - workerAutoComplete = true; - - window.agenticAdmin = { pluginUrl: 'http://test/' }; - global.Worker = FakeWorker; - localStorage.clear(); - - mockExecuteAbility = jest.fn(); - mockVectorStore = { - init: jest.fn().mockResolvedValue(), - clear: jest.fn().mockResolvedValue(), - buildFromEmbeddings: jest.fn().mockResolvedValue( 1 ), - }; - - jest.doMock( '../agentic-abilities-api', () => ( { - executeAbility: mockExecuteAbility, - } ) ); - jest.doMock( '../vector-store', () => ( { - __esModule: true, - default: mockVectorStore, - } ) ); - jest.doMock( '../../utils/logger', () => ( { - createLogger: () => ( { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - } ), - } ) ); - - kb = require( '../knowledge-base' ); - } ); - - afterEach( () => { - delete global.Worker; - delete window.agenticAdmin; - localStorage.clear(); - } ); - - /** - * Wire mockExecuteAbility to return one chunk per extraction phase. - */ - function stubSuccessfulExtraction() { - mockExecuteAbility.mockImplementation( ( id ) => { - switch ( id ) { - case 'agentic-admin/codebase-extract': - return Promise.resolve( { - chunks: [ - { - path: 'theme/style.php', - start_line: 1, - end_line: 10, - content: 'x', - type: 'code', - }, - ], - total_files: 1, - has_more: false, - } ); - case 'agentic-admin/schema-extract': - return Promise.resolve( { - chunks: [ - { - path: 'wp_posts', - start_line: 0, - end_line: 0, - content: 'schema', - type: 'schema', - }, - ], - total_tables: 1, - } ); - case 'agentic-admin/wp-api-extract': - return Promise.resolve( { - chunks: [ - { - path: 'wp_query', - start_line: 0, - end_line: 0, - content: 'api', - type: 'api', - }, - ], - } ); - case 'agentic-admin/docs-extract': - return Promise.resolve( { - chunks: [ - { - path: 'docs/readme.md', - start_line: 0, - end_line: 0, - content: 'doc', - type: 'docs', - }, - ], - } ); - default: - return Promise.resolve( null ); - } - } ); - } - - describe( 'initial state', () => { - it( 'isBuilding() returns false', () => { - expect( kb.isBuilding() ).toBe( false ); - } ); - - it( 'getProgress() returns null', () => { - expect( kb.getProgress() ).toBeNull(); - } ); - - it( 'getError() returns null', () => { - expect( kb.getError() ).toBeNull(); - } ); - - it( 'getKBStatus() returns null when nothing is persisted', () => { - expect( kb.getKBStatus() ).toBeNull(); - } ); - - it( 'getKBStatus() returns null when localStorage holds invalid JSON', () => { - localStorage.setItem( 'agentic_admin_kb_status', '{not-json' ); - expect( kb.getKBStatus() ).toBeNull(); - } ); - } ); - - describe( 'subscribe', () => { - it( 'returns a function', () => { - const unsubscribe = kb.subscribe( () => {} ); - expect( typeof unsubscribe ).toBe( 'function' ); - unsubscribe(); - } ); - - it( 'fires listeners on state changes', async () => { - const listener = jest.fn(); - kb.subscribe( listener ); - await kb.clearIndex(); - expect( listener ).toHaveBeenCalled(); - } ); - - it( 'stops firing after unsubscribe', async () => { - const listener = jest.fn(); - const unsubscribe = kb.subscribe( listener ); - unsubscribe(); - await kb.clearIndex(); - expect( listener ).not.toHaveBeenCalled(); - } ); - - it( 'isolates a throwing listener from other subscribers', async () => { - const ok = jest.fn(); - kb.subscribe( () => { - throw new Error( 'boom' ); - } ); - kb.subscribe( ok ); - await kb.clearIndex(); - expect( ok ).toHaveBeenCalled(); - } ); - } ); - - describe( 'clearIndex', () => { - it( 'delegates to vectorStore and removes persisted status', async () => { - localStorage.setItem( - 'agentic_admin_kb_status', - JSON.stringify( { totalChunks: 5 } ) - ); - - await kb.clearIndex(); - - expect( mockVectorStore.init ).toHaveBeenCalled(); - expect( mockVectorStore.clear ).toHaveBeenCalled(); - expect( - localStorage.getItem( 'agentic_admin_kb_status' ) - ).toBeNull(); - expect( kb.getProgress() ).toBeNull(); - expect( kb.getError() ).toBeNull(); - } ); - } ); - - describe( 'buildIndex — success path', () => { - it( 'completes a full build, persists status, leaves isBuilding=false', async () => { - stubSuccessfulExtraction(); - - const status = await kb.buildIndex(); - - expect( status ).toMatchObject( { - totalChunks: 1, - codeFiles: 1, - schemaTables: 1, - apiChunks: 1, - docsChunks: 1, - } ); - expect( typeof status.lastIndexed ).toBe( 'number' ); - - expect( kb.isBuilding() ).toBe( false ); - expect( kb.getError() ).toBeNull(); - expect( kb.getProgress() ).toMatchObject( { - phase: 'done', - percent: 100, - } ); - - expect( workerInstances.length ).toBe( 1 ); - expect( workerInstances[ 0 ].terminated ).toBe( true ); - expect( - JSON.parse( localStorage.getItem( 'agentic_admin_kb_status' ) ) - ).toMatchObject( { totalChunks: 1 } ); - } ); - - it( 'notifies subscribers across all phases', async () => { - stubSuccessfulExtraction(); - - const phases = new Set(); - kb.subscribe( () => { - const p = kb.getProgress(); - if ( p ) { - phases.add( p.phase ); - } - } ); - - await kb.buildIndex(); - - // Expect at least one notification per major phase. - expect( phases ).toEqual( - new Set( [ - 'starting', - 'code', - 'schema', - 'api', - 'docs', - 'embedding', - 'done', - ] ) - ); - } ); - } ); - - describe( 'buildIndex — deduplication', () => { - it( 'returns the same worker/extraction for concurrent calls', async () => { - stubSuccessfulExtraction(); - - const [ s1, s2 ] = await Promise.all( [ - kb.buildIndex(), - kb.buildIndex(), - ] ); - - expect( s1 ).toEqual( s2 ); - // Exactly one Worker constructed. - expect( workerInstances.length ).toBe( 1 ); - // Each ability called once, not twice. - expect( mockExecuteAbility ).toHaveBeenCalledTimes( 4 ); - } ); - } ); - - describe( 'buildIndex — error path', () => { - it( 'surfaces a missing plugin URL via getError and isBuilding=false', async () => { - stubSuccessfulExtraction(); - delete window.agenticAdmin; - - await expect( kb.buildIndex() ).rejects.toThrow( - /Plugin URL not available/ - ); - - expect( kb.isBuilding() ).toBe( false ); - expect( kb.getError() ).toMatch( /Plugin URL not available/ ); - } ); - - it( 'surfaces a worker error and resets _building', async () => { - stubSuccessfulExtraction(); - workerAutoComplete = false; - - const buildPromise = kb.buildIndex(); - - // Wait for phases 1-4 to complete and the worker to be constructed. - await new Promise( ( resolve ) => setImmediate( resolve ) ); - - expect( workerInstances.length ).toBe( 1 ); - workerInstances[ 0 ].emit( 'message', { - type: 'error', - message: 'embedding failed', - } ); - - await expect( buildPromise ).rejects.toThrow( /embedding failed/ ); - expect( kb.isBuilding() ).toBe( false ); - expect( kb.getError() ).toBe( 'embedding failed' ); - } ); - - it( 'throws "No content found to index" when every extractor returns empty', async () => { - mockExecuteAbility.mockResolvedValue( { chunks: [] } ); - - await expect( kb.buildIndex() ).rejects.toThrow( - /No content found to index/ - ); - expect( kb.isBuilding() ).toBe( false ); - expect( kb.getError() ).toMatch( /No content found to index/ ); - // No worker should have been spawned. - expect( workerInstances.length ).toBe( 0 ); - } ); - } ); -} ); diff --git a/src/extensions/services/__tests__/react-agent.test.js b/src/extensions/services/__tests__/react-agent.test.js index 09e63e7..1426d2b 100644 --- a/src/extensions/services/__tests__/react-agent.test.js +++ b/src/extensions/services/__tests__/react-agent.test.js @@ -346,7 +346,7 @@ describe( 'ReactAgent', () => { } ); describe( 'Per-call instance state cleanup', () => { - it( 'clears toolFilter / webSearchContext / docSearch after a successful run', async () => { + it( 'clears toolFilter / webSearchContext after a successful run', async () => { mockStreamOnce( mockEngine, '{"action": "final_answer", "content": "ok"}' @@ -355,12 +355,10 @@ describe( 'ReactAgent', () => { await reactAgent.execute( 'hi', [], { toolFilter: [ 'agentic-admin/plugin-list' ], webSearchContext: 'some context', - docSearch: true, } ); expect( reactAgent.currentToolFilter ).toBeNull(); expect( reactAgent.webSearchContext ).toBeNull(); - expect( reactAgent.docSearch ).toBe( false ); } ); it( 'clears state even when execute throws (try/finally guard)', async () => { @@ -373,13 +371,11 @@ describe( 'ReactAgent', () => { reactAgent.execute( 'hi', [], { toolFilter: [ 'agentic-admin/plugin-list' ], webSearchContext: 'some context', - docSearch: true, } ) ).rejects.toThrow( /engine boom/ ); expect( reactAgent.currentToolFilter ).toBeNull(); expect( reactAgent.webSearchContext ).toBeNull(); - expect( reactAgent.docSearch ).toBe( false ); } ); it( 'clears state on the early-return "Model not loaded" path', async () => { @@ -388,14 +384,12 @@ describe( 'ReactAgent', () => { const result = await reactAgent.execute( 'hi', [], { toolFilter: [ 'agentic-admin/plugin-list' ], webSearchContext: 'some context', - docSearch: true, } ); expect( result.success ).toBe( false ); expect( result.error ).toBe( 'Model not loaded' ); expect( reactAgent.currentToolFilter ).toBeNull(); expect( reactAgent.webSearchContext ).toBeNull(); - expect( reactAgent.docSearch ).toBe( false ); } ); } ); diff --git a/src/extensions/services/chat-orchestrator.js b/src/extensions/services/chat-orchestrator.js index 23ab750..2ab8cfa 100644 --- a/src/extensions/services/chat-orchestrator.js +++ b/src/extensions/services/chat-orchestrator.js @@ -18,7 +18,6 @@ import workflowRegistry from './workflow-registry'; import workflowOrchestrator from './workflow-orchestrator'; import ReactAgent from './react-agent'; import messageRouter from './message-router'; -import vectorStore from './vector-store'; import { executeAbility } from './agentic-abilities-api'; import { createLogger } from '../utils/logger'; @@ -275,10 +274,7 @@ class ChatOrchestrator { userMessage, false, options.bundleToolIds, - webSearchContext, - options.bundleIds || - ( options.bundleId ? [ options.bundleId ] : null ), - options.docSearch + webSearchContext ); } @@ -293,14 +289,6 @@ class ChatOrchestrator { ); } - // Knowledge-base intent (book button) bypasses ReAct entirely. - // The user explicitly asked for a docs answer — fishing for tools - // produces hallucinated tool calls on small models (issue #209). - if ( options.docSearch ) { - log.info( 'Routing to doc-search (RAG, no tools)' ); - return await this.processWithDocSearch( userMessage ); - } - // All non-workflow messages go through ReAct. log.info( `Routing to ReAct loop (thinking: ${ @@ -311,9 +299,7 @@ class ChatOrchestrator { userMessage, route.disableThinking, null, - webSearchContext, - null, - options.docSearch + webSearchContext ); } catch ( error ) { log.error( 'Error processing message:', error ); @@ -392,17 +378,13 @@ class ChatOrchestrator { * @param {boolean} disableThinking - Whether to disable model thinking * @param {Array} toolFilter - Optional array of tool IDs to constrain the agent * @param {string|null} webSearchContext - Formatted search results from pre-step - * @param {Array|null} bundleIds - Active bundle identifiers (multi-select) - * @param {boolean} docSearch - Whether to augment prompt with KB vector search results * @return {Promise} Result with success status and ReAct execution details. */ async processWithReact( userMessage, disableThinking = false, toolFilter = null, - webSearchContext = null, - bundleIds = null, - docSearch = false + webSearchContext = null ) { if ( ! this.isLLMReady() ) { this.session.addAssistantMessage( @@ -456,7 +438,7 @@ class ChatOrchestrator { const result = await this.reactAgent.execute( userMessage, this.session.getConversationHistory(), - { toolFilter, webSearchContext, docSearch } + { toolFilter, webSearchContext } ); // For tools that prefer to display their own summary (e.g. read-file), @@ -752,174 +734,6 @@ Explain what went wrong and suggest what the user might try next.`; } } - /** - * Process message via knowledge-base RAG (no tools). - * - * Invoked when the user enabled the docs/book button. Skips ReAct - * entirely, runs a vector search over the indexed knowledge base, and - * streams an LLM answer grounded in the retrieved snippets. If the - * index is empty, falls back to a conversational LLM answer without - * RAG context — but still no tools. - * - * @param {string} userMessage - User's message - * @return {Promise} Result with success status. - */ - async processWithDocSearch( userMessage ) { - if ( ! this.isLLMReady() ) { - this.session.addAssistantMessage( - 'The AI model is not loaded yet. Please load the model first.' - ); - return { success: false, error: 'Model not loaded' }; - } - - // Retrieve RAG context (best-effort; failure falls through to a - // context-less conversational answer). - let ragContext = ''; - let ragHits = 0; - try { - await vectorStore.init(); - if ( vectorStore.isReady() ) { - const results = await vectorStore.search( userMessage, 3 ); - if ( results.length > 0 ) { - ragHits = results.length; - const CHAR_BUDGET = 2500; - let budget = CHAR_BUDGET; - const parts = [ '[Knowledge base context]' ]; - for ( const r of results ) { - const snippet = r.content.slice( - 0, - Math.min( r.content.length, budget ) - ); - parts.push( `${ r.path }:\n${ snippet }` ); - budget -= snippet.length + r.path.length + 3; - if ( budget <= 0 ) { - break; - } - } - parts.push( '[End context]' ); - ragContext = parts.join( '\n\n' ); - } - } - } catch ( err ) { - log.warn( 'Doc-search vector lookup failed:', err.message ); - } - - log.info( - `Doc-search: ${ ragHits } hit(s), context ${ ragContext.length } chars` - ); - - const systemPrompt = ragContext - ? "You are a WordPress assistant answering from the user's indexed knowledge base. Use the provided context to answer accurately. If the context doesn't cover the question, say so plainly — do not invent details." - : 'You are a helpful WordPress assistant. The user has the knowledge-base mode on, but no indexed content matched their question. Answer from general WordPress knowledge and tell them no local docs were found.'; - - // Honor the user's thinking-mode setting. Qwen 3 occasionally ignores - // /nothink and emits anyway, so we also strip blocks below. - const thinkingPrefs = ChatOrchestrator.getThinkingPreferences(); - const suppressThinking = !! thinkingPrefs.disableThinkingBeforeTool; - const nothinkSuffix = suppressThinking ? '\n\n/nothink' : ''; - - const userPrompt = - ( ragContext - ? `${ ragContext }\n\nQuestion: ${ userMessage }` - : userMessage ) + nothinkSuffix; - - const engine = modelLoader.getEngine(); - const messages = [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ]; - - this.callbacks.onStreamStart(); - let fullResponse = ''; - let visibleResponse = ''; - let inThinkBlock = false; - let thinkAnnounced = false; - - const stripThink = ( text ) => - text.replace( /[\s\S]*?<\/think>\s*/g, '' ); - - try { - const stream = await engine.chat.completions.create( { - messages, - temperature: 0.3, - max_tokens: this.llmOptions.maxTokens, - stream: true, - stream_options: { include_usage: true }, - stop: [ 'User:', 'USER:', '\n\nUser' ], - } ); - - for await ( const chunk of stream ) { - if ( this.currentAbortController?.signal.aborted ) { - break; - } - const delta = chunk.choices[ 0 ]?.delta?.content || ''; - fullResponse += delta; - - // Detect and route tokens. - const hasOpen = fullResponse.includes( '' ); - const hasClose = fullResponse.includes( '' ); - - if ( hasOpen && ! hasClose ) { - if ( ! inThinkBlock ) { - inThinkBlock = true; - if ( ! suppressThinking ) { - thinkAnnounced = true; - this.callbacks.onThinkingStart?.(); - } - } - if ( ! suppressThinking ) { - const thinkStart = - fullResponse.indexOf( '' ) + 7; - const thinkContent = fullResponse - .substring( thinkStart ) - .trim(); - this.callbacks.onThinkingChunk?.( delta, thinkContent ); - } - // Skip onStreamChunk while inside a think block. - continue; - } - - if ( inThinkBlock && hasClose ) { - inThinkBlock = false; - const match = fullResponse.match( - /([\s\S]*?)<\/think>/ - ); - const thinkContent = match ? match[ 1 ].trim() : ''; - if ( ! suppressThinking ) { - this.callbacks.onThinkingEnd?.( thinkContent || null ); - } - } - - visibleResponse = stripThink( fullResponse ).trimStart(); - this.callbacks.onStreamChunk( delta, visibleResponse ); - - if ( chunk.usage ) { - modelLoader.updateUsageStats( chunk.usage ); - } - } - - // Edge case: model produced without closing tag. - if ( inThinkBlock && thinkAnnounced ) { - this.callbacks.onThinkingEnd?.( null ); - } - - const finalText = stripThink( fullResponse ).trim(); - this.session.addAssistantMessage( - finalText, - this.getUsageStatsMeta() - ); - this.callbacks.onStreamEnd( finalText ); - return { success: true }; - } catch ( error ) { - log.error( 'Doc-search LLM stream failed:', error ); - const fallback = - "I couldn't complete the knowledge-base answer. Try again, or toggle the knowledge-base button off to use tools."; - this.session.addAssistantMessage( fallback ); - this.callbacks.onStreamEnd( fallback ); - return { success: false, error: error.message }; - } - } - /** * Process message with a registered workflow * diff --git a/src/extensions/services/indexing-worker.js b/src/extensions/services/indexing-worker.js deleted file mode 100644 index 68dd567..0000000 --- a/src/extensions/services/indexing-worker.js +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Indexing Worker - * - * Runs Transformers.js embedding inside a Web Worker to avoid blocking - * the main thread during knowledge base builds. Only handles the slow - * part (neural network inference); the main thread builds the index - * and persists to IndexedDB. - * - * Message protocol: - * In: { type: 'embed', chunks: Array<{path, start_line, end_line, content, type}> } - * Out: { type: 'progress', done: number, total: number, message: string } - * { type: 'complete', embeddings: Array, chunkMetadata: Array } - * { type: 'error', message: string } - * - * @since 0.14.0 - */ - -/* eslint-disable no-console */ - -// Pinned to a specific patch version so a compromised CDN range can't silently -// ship new code to every user. Bump deliberately when upgrading. -const TRANSFORMERS_CDN = - 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1'; -const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2'; -const BATCH_SIZE = 10; - -/** @type {Function|null} Cached embedding pipeline. */ -let embeddingPipeline = null; - -/** - * Load the Transformers.js embedding pipeline from CDN. - * - * @return {Promise} - */ -async function loadPipeline() { - if ( embeddingPipeline ) { - return; - } - - console.log( '[indexing-worker] Loading Transformers.js from CDN...' ); - - const transformers = await import( - /* webpackIgnore: true */ TRANSFORMERS_CDN - ); - - console.log( '[indexing-worker] Loading embedding model...' ); - - embeddingPipeline = await transformers.pipeline( - 'feature-extraction', - MODEL_NAME, - { - device: 'wasm', - progress_callback: ( progress ) => { - if ( progress.status === 'download' && progress.total ) { - const pct = Math.round( - ( progress.loaded / progress.total ) * 100 - ); - self.postMessage( { - type: 'progress', - done: 0, - total: 0, - message: `Downloading model: ${ pct }% (${ progress.file })`, - } ); - } - }, - } - ); - - console.log( '[indexing-worker] Embedding pipeline ready (WASM).' ); -} - -/** - * Embed a text string into a vector. - * - * @param {string} text Text to embed. - * @return {Promise} Embedding vector. - */ -async function embed( text ) { - const truncated = text.slice( 0, 2000 ); - const output = await embeddingPipeline( truncated, { - pooling: 'mean', - normalize: true, - } ); - return Array.from( output.data ); -} - -/** - * Handle incoming messages from the main thread. - */ -self.addEventListener( 'message', async ( event ) => { - const { type, chunks } = event.data; - - if ( type !== 'embed' ) { - return; - } - - try { - self.postMessage( { - type: 'progress', - done: 0, - total: chunks.length, - message: `Loading embedding model (first run downloads ~23MB)...`, - } ); - - await loadPipeline(); - - const embeddings = []; - const chunkMetadata = []; - let done = 0; - - for ( let i = 0; i < chunks.length; i += BATCH_SIZE ) { - const batch = chunks.slice( i, i + BATCH_SIZE ); - - for ( const chunk of batch ) { - try { - const searchText = `${ chunk.path }:${ chunk.start_line }-${ chunk.end_line }\n${ chunk.content }`; - const embedding = await embed( searchText ); - const id = String( embeddings.length ); - - embeddings.push( { - id, - title: `${ chunk.path }:${ chunk.start_line }`, - url: chunk.path, - embeddings: embedding, - } ); - - chunkMetadata.push( { - id, - path: chunk.path, - start_line: chunk.start_line, - end_line: chunk.end_line, - content: chunk.content, - type: chunk.type, - } ); - - done++; - } catch ( err ) { - console.warn( - `[indexing-worker] Failed to embed chunk ${ chunk.path }:${ chunk.start_line }:`, - err.message - ); - } - } - - self.postMessage( { - type: 'progress', - done, - total: chunks.length, - message: `Embedding chunks: ${ done }/${ chunks.length }...`, - } ); - } - - console.log( `[indexing-worker] Done. Embedded ${ done } chunks.` ); - - self.postMessage( { - type: 'complete', - embeddings, - chunkMetadata, - } ); - } catch ( err ) { - console.error( '[indexing-worker] Fatal error:', err ); - self.postMessage( { - type: 'error', - message: err.message, - } ); - } -} ); diff --git a/src/extensions/services/knowledge-base.js b/src/extensions/services/knowledge-base.js deleted file mode 100644 index fd16c1b..0000000 --- a/src/extensions/services/knowledge-base.js +++ /dev/null @@ -1,397 +0,0 @@ -/** - * Knowledge Base Service - * - * Orchestrates indexing from all knowledge sources (code, schema, API, docs) - * into the vector store. Maintains singleton state so the build survives - * component unmounts (e.g. switching tabs) and any component can subscribe - * to live progress updates. - * - * @see src/extensions/services/vector-store.js - */ - -import { executeAbility } from './agentic-abilities-api'; -import vectorStore from './vector-store'; -import { createLogger } from '../utils/logger'; - -const log = createLogger( 'KnowledgeBase' ); - -const STATUS_KEY = 'agentic_admin_kb_status'; - -/* ── Singleton state ─────────────────────────────────────────────────── */ - -let _building = false; -let _progress = null; -let _error = null; -let _buildPromise = null; - -/** @type {Set} */ -const _listeners = new Set(); - -function _notify() { - for ( const fn of _listeners ) { - try { - fn(); - } catch { - // Listener threw — ignore to protect other subscribers. - } - } -} - -/** - * Subscribe to state changes. Returns an unsubscribe function. - * - * @param {Function} listener Called on every state change. - * @return {Function} Unsubscribe. - */ -export function subscribe( listener ) { - _listeners.add( listener ); - return () => _listeners.delete( listener ); -} - -/** - * @return {boolean} Whether a build is in progress. - */ -export function isBuilding() { - return _building; -} - -/** - * @return {{phase: string, message: string, percent: number}|null} - */ -export function getProgress() { - return _progress; -} - -/** - * @return {string|null} Last build error, if any. - */ -export function getError() { - return _error; -} - -/* ── localStorage helpers ────────────────────────────────────────────── */ - -/** - * @typedef {Object} KBStatus - * @property {number} lastIndexed - Timestamp of last successful index - * @property {number} totalChunks - Total chunks in the index - * @property {number} codeFiles - Number of code files indexed - * @property {number} schemaTables - Number of DB tables indexed - * @property {number} apiChunks - Number of WP API signature chunks - * @property {number} docsChunks - Number of reference doc chunks - */ - -/** - * Get the saved knowledge base status. - * - * @return {KBStatus|null} Saved status or null. - */ -export function getKBStatus() { - try { - const saved = localStorage.getItem( STATUS_KEY ); - return saved ? JSON.parse( saved ) : null; - } catch { - return null; - } -} - -function saveKBStatus( status ) { - localStorage.setItem( STATUS_KEY, JSON.stringify( status ) ); -} - -function clearKBStatus() { - localStorage.removeItem( STATUS_KEY ); -} - -/* ── Worker helper ───────────────────────────────────────────────────── */ - -/** - * Embed chunks in a Web Worker, then build the index on the main thread. - * - * @param {Object[]} chunks Chunks to index. - * @param {Function} onProgress Callback: (done, total, message) => void. - * @return {Promise} Number of chunks indexed. - */ -function indexInWorker( chunks, onProgress ) { - return new Promise( ( resolve, reject ) => { - const pluginUrl = window.agenticAdmin?.pluginUrl; - - if ( ! pluginUrl ) { - reject( new Error( 'Plugin URL not available for worker.' ) ); - return; - } - - const worker = new Worker( - pluginUrl + 'build-extensions/indexing-worker.js' - ); - - worker.addEventListener( 'message', async ( e ) => { - const msg = e.data; - - if ( msg.type === 'progress' ) { - onProgress( msg.done, msg.total, msg.message ); - } else if ( msg.type === 'complete' ) { - worker.terminate(); - - // Build the index on the main thread (fast, < 1s). - try { - onProgress( - msg.chunkMetadata.length, - msg.chunkMetadata.length, - 'Building search index...' - ); - const count = await vectorStore.buildFromEmbeddings( - msg.embeddings, - msg.chunkMetadata - ); - log.info( `Index built: ${ count } chunks.` ); - resolve( count ); - } catch ( err ) { - reject( err ); - } - } else if ( msg.type === 'error' ) { - log.error( 'Worker error:', msg.message ); - worker.terminate(); - reject( new Error( msg.message ) ); - } - } ); - - worker.addEventListener( 'error', ( e ) => { - log.error( 'Worker crashed:', e.message ); - worker.terminate(); - reject( new Error( `Worker crashed: ${ e.message }` ) ); - } ); - - worker.postMessage( { type: 'embed', chunks } ); - } ); -} - -/* ── Progress helper ─────────────────────────────────────────────────── */ - -function setProgress( update ) { - _progress = update; - _notify(); -} - -/* ── Public API ──────────────────────────────────────────────────────── */ - -/** - * Build the knowledge base index from all sources. - * If a build is already running, returns the existing promise. - * - * @return {Promise} Final index status. - */ -export async function buildIndex() { - // Deduplicate: if already building, return existing promise. - if ( _building && _buildPromise ) { - return _buildPromise; - } - - _building = true; - _error = null; - _progress = { phase: 'starting', message: 'Starting...', percent: 0 }; - _notify(); - - _buildPromise = _runBuild(); - - try { - const status = await _buildPromise; - return status; - } finally { - _buildPromise = null; - } -} - -async function _runBuild() { - try { - log.info( 'Starting knowledge base build...' ); - - await vectorStore.init(); - await vectorStore.clear(); - - let allChunks = []; - let totalFiles = 0; - let schemaTables = 0; - let apiChunks = 0; - let docsChunks = 0; - - // Phase 1: Code extraction (~40%). - setProgress( { - phase: 'code', - message: 'Extracting code from active theme and plugins...', - percent: 5, - } ); - - let offset = 0; - let hasMore = true; - - while ( hasMore ) { - try { - const page = await executeAbility( - 'agentic-admin/codebase-extract', - { offset, limit: 50 } - ); - - if ( ! page || ! page.chunks ) { - break; - } - - allChunks = allChunks.concat( page.chunks ); - totalFiles = page.total_files || totalFiles; - hasMore = page.has_more || false; - offset += 50; - - const pct = Math.min( - 35, - 5 + - Math.round( - ( offset / Math.max( totalFiles, 1 ) ) * 30 - ) - ); - setProgress( { - phase: 'code', - message: `Extracted ${ offset } of ~${ totalFiles } files...`, - percent: pct, - } ); - } catch ( e ) { - log.warn( 'Code extraction page failed:', e.message ); - break; - } - } - - // Phase 2: Database schema (~10%). - setProgress( { - phase: 'schema', - message: 'Extracting database schema...', - percent: 40, - } ); - - try { - const schema = await executeAbility( - 'agentic-admin/schema-extract', - {} - ); - if ( schema && schema.chunks && schema.chunks.length ) { - allChunks = allChunks.concat( schema.chunks ); - schemaTables = schema.total_tables || 0; - } - } catch ( e ) { - log.warn( 'Schema extraction failed (best-effort):', e.message ); - } - - // Phase 3: WP API signatures (~10%). - setProgress( { - phase: 'api', - message: 'Extracting WordPress API signatures...', - percent: 50, - } ); - - try { - const api = await executeAbility( - 'agentic-admin/wp-api-extract', - {} - ); - if ( api && api.chunks && api.chunks.length ) { - apiChunks = api.chunks.length; - allChunks = allChunks.concat( api.chunks ); - } - } catch ( e ) { - log.warn( 'API extraction failed (best-effort):', e.message ); - } - - // Phase 4: Reference docs (~5%). - setProgress( { - phase: 'docs', - message: 'Extracting reference documentation...', - percent: 60, - } ); - - try { - const docs = await executeAbility( - 'agentic-admin/docs-extract', - {} - ); - if ( docs && docs.chunks && docs.chunks.length ) { - docsChunks = docs.chunks.length; - allChunks = allChunks.concat( docs.chunks ); - } - } catch ( e ) { - log.warn( 'Docs extraction failed (best-effort):', e.message ); - } - - if ( allChunks.length === 0 ) { - throw new Error( 'No content found to index.' ); - } - - // Phase 5: Embedding (~35%) — Web Worker. - setProgress( { - phase: 'embedding', - message: `Embedding ${ allChunks.length } chunks in background (first run downloads ~23MB model)...`, - percent: 65, - } ); - - const indexed = await indexInWorker( - allChunks, - ( done, total, msg ) => { - const pct = - total > 0 ? 65 + Math.round( ( done / total ) * 30 ) : 65; - setProgress( { - phase: 'embedding', - message: msg, - percent: Math.min( pct, 95 ), - } ); - } - ); - - const status = { - lastIndexed: Date.now(), - totalChunks: indexed, - codeFiles: totalFiles, - schemaTables, - apiChunks, - docsChunks, - }; - - saveKBStatus( status ); - - _building = false; - setProgress( { - phase: 'done', - message: 'Knowledge base ready!', - percent: 100, - } ); - - log.info( 'Knowledge base build complete:', status ); - return status; - } catch ( err ) { - _error = err.message; - _building = false; - _notify(); - throw err; - } -} - -/** - * Clear the knowledge base index. - * - * @return {Promise} - */ -export async function clearIndex() { - await vectorStore.init(); - await vectorStore.clear(); - clearKBStatus(); - _progress = null; - _error = null; - _notify(); - log.info( 'Knowledge base cleared.' ); -} - -export default { - buildIndex, - clearIndex, - getKBStatus, - subscribe, - isBuilding, - getProgress, - getError, -}; diff --git a/src/extensions/services/react-agent.js b/src/extensions/services/react-agent.js index 311081f..9571ee4 100644 --- a/src/extensions/services/react-agent.js +++ b/src/extensions/services/react-agent.js @@ -11,7 +11,6 @@ */ import { createLogger } from '../utils/logger'; -import vectorStore from './vector-store'; const log = createLogger( 'ReactAgent' ); @@ -118,10 +117,9 @@ class ReactAgent { async execute( userMessage, conversationHistory = [], options = {} ) { log.info( 'Starting ReAct loop for:', userMessage ); - // Store tool filter, web search context, and doc search for this execution + // Store tool filter and web search context for this execution this.currentToolFilter = options.toolFilter || null; this.webSearchContext = options.webSearchContext || null; - this.docSearch = options.docSearch || false; try { const engine = this.modelLoader.getEngine(); @@ -150,7 +148,6 @@ class ReactAgent { // even if executeWithPromptBased throws. this.currentToolFilter = null; this.webSearchContext = null; - this.docSearch = false; } } @@ -171,52 +168,10 @@ class ReactAgent { const toolsUsed = []; let iteration = 0; - // Auto-consult: inject relevant RAG context before the ReAct loop. - // Only runs when the user has enabled the knowledge base toggle. - let augmentedMessage = userMessage; - try { - if ( this.docSearch ) { - await vectorStore.init(); - if ( vectorStore.isReady() ) { - const ragResults = await vectorStore.search( - userMessage, - 2 - ); - if ( ragResults.length > 0 ) { - // Budget: ~1750 chars max for injected context. - let context = '[Relevant knowledge base context]\n'; - let charBudget = 1750; - - for ( const result of ragResults ) { - const snippet = result.content.slice( - 0, - Math.min( result.content.length, charBudget ) - ); - context += `${ result.path }: ${ snippet }\n`; - charBudget -= - snippet.length + result.path.length + 3; - if ( charBudget <= 0 ) { - break; - } - } - - context += '[End context]\n\n'; - augmentedMessage = context + userMessage; - log.info( - `Auto-consult: injected ${ ragResults.length } RAG results (${ context.length } chars)` - ); - } - } - } - } catch ( e ) { - // Auto-consult is best-effort — don't block the agent. - log.warn( 'Auto-consult RAG failed:', e.message ); - } - const messages = [ { role: 'system', content: systemPrompt }, ...conversationHistory, - { role: 'user', content: augmentedMessage }, + { role: 'user', content: userMessage }, ]; // Inject web search results as context before the user message diff --git a/src/extensions/services/vector-store.js b/src/extensions/services/vector-store.js deleted file mode 100644 index 7e157a3..0000000 --- a/src/extensions/services/vector-store.js +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Vector Store Service - * - * In-browser RAG vector store using Transformers.js (CDN) + a plain-JS - * similarity index + IndexedDB persistence. Embeds code chunks and enables - * semantic search. - * - * - Transformers.js loaded lazily from CDN (~100MB, not bundled) - * - Embeddings run on CPU (WASM) to avoid GPU contention with the LLM - * - Vectors + chunk metadata persisted in IndexedDB - * - * Search is an exhaustive cosine scan. The embedding model emits L2-normalised - * vectors (`normalize: true`), so cosine similarity is a plain dot product. - * At 384 dimensions a few thousand chunks score in single-digit milliseconds, - * which is well under the embedding cost of the query itself, so an ANN index - * would buy nothing at this scale. - */ - -import { createLogger } from '../utils/logger'; - -const log = createLogger( 'VectorStore' ); - -const TRANSFORMERS_CDN = - 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1'; -const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2'; -const DB_NAME = 'wp-agentic-rag-db'; -// v2 replaced the voy-search index store with plain Float32Array vectors. -const DB_VERSION = 2; -const STORE_INDEX = 'embedding-index'; -const LEGACY_STORE_INDEX = 'voy-index'; -const STORE_CHUNKS = 'chunk-metadata'; -const BATCH_SIZE = 10; - -/** - * @type {import('@huggingface/transformers').Pipeline|null} - */ -let pipeline = null; - -/** - * Indexed vectors, positionally aligned with `chunkMetadata`. - * - * @type {Float32Array[]} - */ -let vectors = []; - -/** - * @type {Array} - */ -let chunkMetadata = []; - -/** - * @type {boolean} - */ -let initialized = false; - -/** - * @type {boolean} - */ -let initializing = false; - -/** - * Open the IndexedDB database. - * - * @return {Promise} The database instance. - */ -function openDB() { - return new Promise( ( resolve, reject ) => { - const request = indexedDB.open( DB_NAME, DB_VERSION ); - - request.onupgradeneeded = ( event ) => { - const db = event.target.result; - if ( ! db.objectStoreNames.contains( STORE_INDEX ) ) { - db.createObjectStore( STORE_INDEX ); - } - if ( ! db.objectStoreNames.contains( STORE_CHUNKS ) ) { - db.createObjectStore( STORE_CHUNKS ); - } - // Drop the old voy-search index; its serialised format is not - // readable here. Affected users re-index from the settings screen. - if ( db.objectStoreNames.contains( LEGACY_STORE_INDEX ) ) { - db.deleteObjectStore( LEGACY_STORE_INDEX ); - } - }; - - request.onsuccess = () => resolve( request.result ); - request.onerror = () => reject( request.error ); - } ); -} - -/** - * Get a value from IndexedDB. - * - * @param {string} storeName Object store name. - * @param {string} key Key to retrieve. - * @return {Promise} The stored value. - */ -async function dbGet( storeName, key ) { - const db = await openDB(); - return new Promise( ( resolve, reject ) => { - const tx = db.transaction( storeName, 'readonly' ); - const store = tx.objectStore( storeName ); - const request = store.get( key ); - request.onsuccess = () => resolve( request.result ); - request.onerror = () => reject( request.error ); - } ); -} - -/** - * Put a value into IndexedDB. - * - * @param {string} storeName Object store name. - * @param {string} key Key to store under. - * @param {any} value Value to store. - * @return {Promise} - */ -async function dbPut( storeName, key, value ) { - const db = await openDB(); - return new Promise( ( resolve, reject ) => { - const tx = db.transaction( storeName, 'readwrite' ); - const store = tx.objectStore( storeName ); - const request = store.put( value, key ); - request.onsuccess = () => resolve(); - request.onerror = () => reject( request.error ); - } ); -} - -/** - * Load the embedding pipeline from Transformers.js (CDN). - * - * @return {Promise} - */ -async function loadPipeline() { - if ( pipeline ) { - return; - } - - log.info( 'Loading Transformers.js from CDN...' ); - - let transformers; - try { - transformers = await import( - /* webpackIgnore: true */ TRANSFORMERS_CDN - ); - } catch ( err ) { - throw new Error( - `Failed to load Transformers.js from CDN: ${ err.message }. Check your internet connection.` - ); - } - - log.info( - 'Transformers.js loaded. Downloading embedding model (first run ~23MB, cached after)...' - ); - - try { - pipeline = await transformers.pipeline( - 'feature-extraction', - MODEL_NAME, - { - device: 'wasm', - progress_callback: ( progress ) => { - if ( progress.status === 'download' && progress.total ) { - const pct = Math.round( - ( progress.loaded / progress.total ) * 100 - ); - log.info( - `Downloading model: ${ pct }% (${ progress.file })` - ); - } else if ( progress.status === 'ready' ) { - log.info( 'Model files ready.' ); - } - }, - } - ); - } catch ( err ) { - throw new Error( - `Failed to load embedding model "${ MODEL_NAME }": ${ err.message }` - ); - } - - log.info( 'Embedding pipeline ready (CPU/WASM).' ); -} - -/** - * Convert worker/index embedding entries into the internal vector array. - * - * Entries use the `{ id, title, url, embeddings }` shape produced by - * `indexing-worker.js`; only the vector itself is retained, positionally. - * - * @param {Object[]} entries Embedding entries. - * @return {Float32Array[]} Vectors. - */ -function toVectors( entries ) { - return entries.map( ( entry ) => Float32Array.from( entry.embeddings ) ); -} - -/** - * Restore the index and chunk metadata from IndexedDB. - * - * Vectors and metadata are positionally aligned, so a length mismatch (a - * partial write, or the v1 → v2 upgrade dropping the old voy index) resets - * both rather than serving results against the wrong metadata. - * - * @return {Promise} - */ -async function loadIndex() { - if ( vectors.length ) { - return; - } - - try { - const savedVectors = await dbGet( STORE_INDEX, 'current' ); - const savedChunks = await dbGet( STORE_CHUNKS, 'current' ); - - if ( - Array.isArray( savedVectors ) && - Array.isArray( savedChunks ) && - savedVectors.length === savedChunks.length && - savedVectors.length > 0 - ) { - vectors = savedVectors.map( ( v ) => Float32Array.from( v ) ); - chunkMetadata = savedChunks; - log.info( - `Restored index from IndexedDB (${ chunkMetadata.length } chunks).` - ); - return; - } - - if ( savedVectors || savedChunks ) { - log.warn( - 'Persisted index is incomplete or from an older format; starting empty. Re-index to rebuild.' - ); - } - } catch ( err ) { - log.warn( 'Could not restore index from IndexedDB:', err.message ); - } - - vectors = []; - chunkMetadata = []; - log.info( 'Created new empty index.' ); -} - -/** - * Initialize the vector store. - * Restores any persisted index, defers Transformers.js until needed. - * - * @return {Promise} - */ -async function init() { - if ( initialized || initializing ) { - return; - } - - initializing = true; - - try { - await loadIndex(); - initialized = true; - log.info( 'Vector store initialized.' ); - } catch ( err ) { - log.error( 'Vector store init failed:', err.message ); - throw err; - } finally { - initializing = false; - } -} - -/** - * Embed a text string into a vector. - * - * @param {string} text Text to embed. - * @return {Promise} Embedding vector. - */ -async function embed( text ) { - await loadPipeline(); - - // Truncate to ~512 tokens worth of text (~2000 chars). - const truncated = text.slice( 0, 2000 ); - - const output = await pipeline( truncated, { - pooling: 'mean', - normalize: true, - } ); - - return Array.from( output.data ); -} - -/** - * Persist the current vectors and chunk metadata to IndexedDB. - * - * Float32Array survives the structured clone algorithm, so the vectors are - * stored as-is rather than being stringified. - * - * @return {Promise} - */ -async function persist() { - try { - await dbPut( STORE_INDEX, 'current', vectors ); - await dbPut( STORE_CHUNKS, 'current', chunkMetadata ); - log.debug( `Persisted index (${ chunkMetadata.length } chunks).` ); - } catch ( err ) { - log.warn( 'Failed to persist index:', err.message ); - } -} - -/** - * Index an array of code chunks. - * Embeds in batches and rebuilds the index. - * - * @param {Object[]} chunks Array of { path, start_line, end_line, content, type }. - * @param {Function} [onProgress] Optional callback: (indexed, total) => void. - * @return {Promise} Number of chunks indexed. - */ -async function index( chunks, onProgress ) { - if ( ! initialized ) { - await init(); - } - - // Ensure pipeline is loaded for embedding. - await loadPipeline(); - - // Reset index with fresh data. chunkMetadata is rebuilt alongside - // `embeddings` and must be cleared with it, or a second index() run leaves - // the two positionally misaligned and search returns the wrong chunks. - const embeddings = []; - chunkMetadata = []; - let indexed = 0; - - for ( let i = 0; i < chunks.length; i += BATCH_SIZE ) { - const batch = chunks.slice( i, i + BATCH_SIZE ); - - for ( const chunk of batch ) { - try { - // Build searchable text: path + content. - const searchText = `${ chunk.path }:${ chunk.start_line }-${ chunk.end_line }\n${ chunk.content }`; - const embedding = await embed( searchText ); - const id = String( embeddings.length ); - - embeddings.push( { - id, - title: `${ chunk.path }:${ chunk.start_line }`, - url: chunk.path, - embeddings: embedding, - } ); - - chunkMetadata.push( { - id, - path: chunk.path, - start_line: chunk.start_line, - end_line: chunk.end_line, - content: chunk.content, - type: chunk.type, - } ); - - indexed++; - } catch ( err ) { - log.warn( - `Failed to embed chunk ${ chunk.path }:${ chunk.start_line }:`, - err.message - ); - } - } - - log.info( `Embedded ${ indexed }/${ chunks.length } chunks...` ); - - if ( onProgress ) { - onProgress( indexed, chunks.length ); - } - } - - // Build the new index from all embeddings. - vectors = toVectors( embeddings ); - - // Persist to IndexedDB. - await persist(); - - log.info( `Indexed ${ indexed } chunks.` ); - return indexed; -} - -/** - * Cosine similarity between two L2-normalised vectors. - * - * Both operands come from the embedding pipeline with `normalize: true`, so - * the magnitudes are 1 and the dot product *is* the cosine. - * - * @param {Float32Array} a First vector. - * @param {Float32Array} b Second vector. - * @return {number} Similarity in [-1, 1]. - */ -function cosine( a, b ) { - let dot = 0; - for ( let i = 0; i < a.length; i++ ) { - dot += a[ i ] * b[ i ]; - } - return dot; -} - -/** - * Search the vector index for relevant code chunks. - * - * @param {string} query Query text. - * @param {number} [topK=3] Number of results to return. - * @return {Promise} Array of { path, start_line, end_line, content, type, score }. - */ -async function search( query, topK = 3 ) { - if ( ! initialized || vectors.length === 0 || chunkMetadata.length === 0 ) { - return []; - } - - const queryVector = Float32Array.from( await embed( query ) ); - - const ranked = vectors - .map( ( vector, idx ) => ( { - idx, - score: cosine( queryVector, vector ), - } ) ) - .sort( ( a, b ) => b.score - a.score ) - .slice( 0, topK ); - - log.info( - 'Search results:', - ranked.map( - ( r ) => - `${ r.idx }: ${ - chunkMetadata[ r.idx ]?.path - } (${ r.score.toFixed( 3 ) })` - ) - ); - - return ranked.map( ( { idx, score } ) => ( { - ...chunkMetadata[ idx ], - // Real cosine similarity, unlike the inverse-rank placeholder the - // previous ANN index forced (it returned no distances). - score: Number( score.toFixed( 3 ) ), - } ) ); -} - -/** - * Check if the vector store has an index ready for searching. - * - * @return {boolean} True if index is ready. - */ -function isReady() { - return initialized && chunkMetadata.length > 0; -} - -/** - * Get the number of indexed chunks. - * - * @return {number} Chunk count. - */ -function getChunkCount() { - return chunkMetadata.length; -} - -/** - * Reload the index and chunk metadata from IndexedDB. - * Used after the indexing worker finishes persisting new data. - * - * @return {Promise} - */ -async function reload() { - vectors = []; - chunkMetadata = []; - initialized = false; - initializing = false; - await loadIndex(); - initialized = true; - log.info( - `Reloaded index from IndexedDB (${ chunkMetadata.length } chunks).` - ); -} - -/** - * Build the index from pre-computed embeddings and persist. - * Used after the indexing worker returns embeddings from a background thread. - * - * @param {Object[]} embeddings Pre-computed embedding entries. - * @param {Object[]} metadata Chunk metadata array. - * @return {Promise} Number of chunks indexed. - */ -async function buildFromEmbeddings( embeddings, metadata ) { - if ( ! initialized ) { - await init(); - } - - vectors = toVectors( embeddings ); - chunkMetadata = metadata; - - await persist(); - - log.info( - `Built index from ${ metadata.length } pre-computed embeddings.` - ); - return metadata.length; -} - -/** - * Clear the index and persisted data. - * - * @return {Promise} - */ -async function clear() { - vectors = []; - chunkMetadata = []; - await persist(); - log.info( 'Vector store cleared.' ); -} - -const vectorStore = { - init, - embed, - index, - search, - isReady, - getChunkCount, - clear, - reload, - buildFromEmbeddings, -}; - -export default vectorStore; diff --git a/webpack.config.js b/webpack.config.js index c2d8c4d..8637831 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -31,15 +31,6 @@ module.exports = { // Whisper Web Worker — source preserved in src/extensions/services/ // and src/extensions/components/VoiceButton.jsx for v1.4 (per roadmap). // Re-add this entry to ship voice input again. - - // Indexing Web Worker - background embedding - 'indexing-worker': { - import: path.resolve( - __dirname, - 'src/extensions/services/indexing-worker.js' - ), - filename: 'indexing-worker.js', - }, }, output: { @@ -55,9 +46,8 @@ module.exports = { ...defaultConfig.optimization?.splitChunks, cacheGroups: { ...defaultConfig.optimization?.splitChunks?.cacheGroups, - // Don't split the service worker or web workers + // Don't split the service worker sw: false, - 'indexing-worker': false, }, }, runtimeChunk: false, // SW needs runtime included