# desktop-mode/1.0.1/includes/ai-copilot/abilities.php

OpenStation: Desktop Windows, Dock &amp; Virtual Desktops for WP Admin, version 1.0.1. 465 lines.

- Page: https://pluginprobe.com/plugins/desktop-mode/1.0.1/code/includes/ai-copilot/abilities.php
- Raw: https://pluginprobe.com/plugins/desktop-mode/1.0.1/raw/includes/ai-copilot/abilities.php
- Modified: 2026-08-07T20:39:04+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/desktop-mode/1.0.1/code/includes/ai-copilot/abilities.php#L10-L20`.

```php
<?php
/**
 * OpenStation — AI Copilot abilities.
 *
 * The Copilot's tools are WordPress Abilities API abilities: the agent loop
 * offers the model every registered read-only ability (see
 * {@see openstation_ai_search_ability_names()}) and runs a chosen one through
 * `wp_get_ability()->execute()` — permission checks and input validation
 * happen inside `WP_Ability::execute()`.
 *
 * Every ability's `execute_callback` delegates to the existing query handlers
 * (via {@see openstation_ai_search_dispatch_tool()} / the comment scorer) so
 * there is a single implementation of each tool. The ability is the source of
 * truth for the model-facing description + input schema.
 *
 * @package OpenStation
 */

defined( 'ABSPATH' ) || exit;

/**
 * Ability category slug shared by every Copilot ability.
 */
const OPENSTATION_AI_ABILITY_CATEGORY = 'openstation';

/**
 * Registers the `openstation` ability category.
 *
 * @return void
 */
function openstation_ai_register_ability_category() {
	if ( ! function_exists( 'wp_register_ability_category' ) ) {
		return;
	}

	wp_register_ability_category(
		OPENSTATION_AI_ABILITY_CATEGORY,
		array(
			'label'       => __( 'OpenStation', 'desktop-mode' ),
			'description' => __( 'Read-only content search and wp-admin navigation abilities powering the OpenStation AI assistant.', 'desktop-mode' ),
		)
	);
}
add_action( 'wp_abilities_api_categories_init', 'openstation_ai_register_ability_category' );

/**
 * The ability names the Copilot offers the model as tools.
 *
 * Every registered ability marked read-only (`meta.annotations.readonly`) is
 * offered — the Copilot's own search/navigation abilities, plus any read-only
 * ability registered by Core or another plugin. No opt-in: register a
 * read-only ability and the assistant can use it; its `permission_callback`
 * still gates execution.
 *
 * Only read-only abilities are advertised on purpose: a search turn can be
 * driven by attacker-controlled content (comment / post text that lands in a
 * tool result), so the model is never handed an ability that could change the
 * site.
 *
 * @return string[] Fully-namespaced ability names.
 */
function openstation_ai_search_ability_names() {
	if ( ! function_exists( 'wp_get_abilities' ) ) {
		return array();
	}

	$names = array();
	foreach ( wp_get_abilities() as $ability ) {
		if ( ! $ability instanceof WP_Ability ) {
			continue;
		}
		$meta        = (array) $ability->get_meta();
		$annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : array();
		if ( empty( $annotations['readonly'] ) ) {
			continue;
		}
		$names[] = (string) $ability->get_name();
	}

	return $names;
}

/**
 * The model-facing tool name for an ability — the ability name with its
 * namespace stripped and dashes turned into underscores. By design this
 * reproduces the Copilot's historical tool names (`desktop-mode/search-posts`
 * → `search_posts`), so progress labels, the system prompt, and the answer
 * schema keep referring to the same names across the abilities migration.
 *
 * @param string $ability_name Fully-namespaced ability name.
 * @return string
 */
function openstation_ai_ability_tool_name( $ability_name ) {
	$slug = (string) $ability_name;
	$pos  = strpos( $slug, '/' );
	if ( false !== $pos ) {
		$slug = substr( $slug, $pos + 1 );
	}
	// This becomes the model-facing function name; most function-calling
	// providers only accept [a-z0-9_], so normalize anything else (a
	// third-party ability may carry extra slashes or mixed case).
	$slug = strtolower( str_replace( '-', '_', $slug ) );
	$slug = preg_replace( '/[^a-z0-9_]+/', '_', $slug );
	return trim( (string) $slug, '_' );
}

/**
 * Permission callback: any logged-in user who can read the site.
 *
 * Mirrors the read-only search/navigation tools, which were ungated beyond the
 * Copilot's own logged-in requirement.
 *
 * @return bool
 */
function openstation_ai_ability_can_read() {
	return is_user_logged_in() && current_user_can( 'read' );
}

/**
 * A loose object output schema: typed at the top level, permissive on the rest
 * so `WP_Ability::execute()`'s output validation never rejects a valid handler
 * return (the shapes carry optional/nested fields we don't want to freeze).
 *
 * @param array<string,array<string,mixed>> $properties Documented top-level props.
 * @return array<string,mixed>
 */
function openstation_ai_ability_output_schema( array $properties = array() ) {
	return array(
		'type'                 => 'object',
		'additionalProperties' => true,
		// Keep as a plain (associative) array: WordPress's schema validator
		// array-accesses `properties`, and every caller passes at least one
		// property so JSON serialization is still an object. An empty-object
		// `properties` would need a `(object)` cast, but we never emit one.
		'properties'           => $properties,
	);
}

/**
 * Registers every Copilot ability.
 *
 * @return void
 */
function openstation_ai_register_abilities() {
	if ( ! function_exists( 'wp_register_ability' ) ) {
		return;
	}

	$query_offset_input = array(
		'type'                 => 'object',
		'additionalProperties' => false,
		'required'             => array( 'query', 'offset' ),
		'properties'           => array(
			'query'  => array(
				'type'        => 'string',
				'description' => 'Keyword search terms matched against the title and content (WordPress native search). Distil the user\'s request to the essential nouns — e.g. for "that post I wrote about making paella" pass "paella". Avoid stop-words and full sentences.',
			),
			'offset' => array(
				'type'        => 'integer',
				'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
			),
		),
	);

	$search_output = openstation_ai_ability_output_schema(
		array(
			'items'    => array(
				'type'        => 'array',
				'description' => 'Matching entities with identity, excerpt, and URLs.',
			),
			'count'    => array(
				'type'        => 'integer',
				'description' => 'Number of items in this batch.',
			),
			'total'    => array(
				'type'        => 'integer',
				'description' => 'Total matches across all batches.',
			),
			'has_more' => array(
				'type'        => 'boolean',
				'description' => 'Whether another batch is available at the next offset.',
			),
		)
	);

	$readonly_meta = array(
		'annotations'  => array(
			'readonly'   => true,
			'idempotent' => true,
		),
		'show_in_rest' => true,
		'mcp'          => array(
			'public' => true,
			'type'   => 'tool',
		),
	);

	// Admin-only abilities are still read-only, but must not be exposed to
	// external agents over MCP.
	$readonly_private_meta = array(
		'annotations'  => array(
			'readonly'   => true,
			'idempotent' => true,
		),
		'show_in_rest' => true,
	);

	wp_register_ability(
		'desktop-mode/search-posts',
		array(
			'label'               => __( 'Search posts', 'desktop-mode' ),
			'description'         => 'Keyword-searches published WordPress blog posts by title and content (WordPress native search). Use this when the user is looking for content they or someone else wrote as a post or article. Pass the key search terms as `query`. Returns up to 10 matching posts with their title, a content excerpt, date, and URLs. If has_more is true, call again with the next offset.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => $query_offset_input,
			'output_schema'       => $search_output,
			'execute_callback'    => static function ( $input ) {
				return openstation_ai_search_dispatch_tool( 'search_posts', (array) $input );
			},
			'permission_callback' => 'openstation_ai_ability_can_read',
			'meta'                => $readonly_meta,
		)
	);

	wp_register_ability(
		'desktop-mode/search-pages',
		array(
			'label'               => __( 'Search pages', 'desktop-mode' ),
			'description'         => 'Keyword-searches published WordPress pages (About, Contact, Services, Portfolio, etc.) by title and content. Use this when the user is looking for a static page, landing page, or informational page on the site. Pass the key search terms as `query`. Returns up to 10 matching pages with their title, a content excerpt, and URLs. If has_more is true, call again with the next offset.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => $query_offset_input,
			'output_schema'       => $search_output,
			'execute_callback'    => static function ( $input ) {
				return openstation_ai_search_dispatch_tool( 'search_pages', (array) $input );
			},
			'permission_callback' => 'openstation_ai_ability_can_read',
			'meta'                => $readonly_meta,
		)
	);

	wp_register_ability(
		'desktop-mode/search-comments',
		array(
			'label'               => __( 'Search comments', 'desktop-mode' ),
			'description'         => 'Keyword-searches approved WordPress comments across ALL posts by their text (WordPress native search). Use this when the user remembers something a reader said but does not know which post it was on. Pass the distinctive words from the comment as `query`. Returns up to 10 matching comments with an excerpt, parent post title, and URLs. If has_more is true, call again with the next offset.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => $query_offset_input,
			'output_schema'       => $search_output,
			'execute_callback'    => static function ( $input ) {
				return openstation_ai_search_dispatch_tool( 'search_comments', (array) $input );
			},
			'permission_callback' => 'openstation_ai_ability_can_read',
			'meta'                => $readonly_meta,
		)
	);

	wp_register_ability(
		'desktop-mode/search-comments-by-post',
		array(
			'label'               => __( 'Search comments on a post', 'desktop-mode' ),
			'description'         => 'Keyword-searches approved comments on a SPECIFIC post by its WordPress ID. Use this when you have already identified a post (via search-posts) and the user\'s query also mentions something a reader said on that post — e.g. "I remember a comment on my Málaga post asking about the Alcazaba at night." Call search-posts first to find the post ID, then call this tool with that ID and the distinctive words as `query`. Much more precise than search-comments when the parent post is known. If has_more is true, call again with the next offset.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => array(
				'type'                 => 'object',
				'additionalProperties' => false,
				'required'             => array( 'post_id', 'query', 'offset' ),
				'properties'           => array(
					'post_id' => array(
						'type'        => 'integer',
						'description' => 'The WordPress ID of the post whose comments should be searched. Obtain this from a prior search-posts call.',
					),
					'query'   => array(
						'type'        => 'string',
						'description' => 'Keyword search terms matched against the comment text. Pass the distinctive words the user remembers; use an empty string to list the post\'s comments without keyword filtering.',
					),
					'offset'  => array(
						'type'        => 'integer',
						'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
					),
				),
			),
			'output_schema'       => $search_output,
			'execute_callback'    => static function ( $input ) {
				return openstation_ai_search_dispatch_tool( 'search_comments_by_post', (array) $input );
			},
			'permission_callback' => 'openstation_ai_ability_can_read',
			'meta'                => $readonly_meta,
		)
	);

	wp_register_ability(
		'desktop-mode/list-admin-pages',
		array(
			'label'               => __( 'List admin pages', 'desktop-mode' ),
			// Describes the TOOL, not the caller's answer format: agents
			// and the Copilot consume the same registry, and the
			// Copilot's `admin_links` / `answer_type` contract exists in
			// its own system prompt and answer schema. Naming those
			// fields here would instruct an agent to emit a shape its
			// answer schema does not have.
			'description'         => 'Returns the full catalog of WordPress admin (wp-admin) destinations — pages for managing posts, categories, users, plugins, themes, settings, etc. Call this when the user asks "where can I find X?", "how do I get to Y?", "where are the settings for Z?" — any navigational question about the admin UI. Each entry carries a title, url, icon, and description, so pick the few most relevant to the query. The catalog is small and stable so one call is enough.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => array(
				'type'                 => 'object',
				'additionalProperties' => false,
				'required'             => array(),
				'properties'           => (object) array(),
			),
			'output_schema'       => openstation_ai_ability_output_schema(
				array(
					'pages' => array(
						'type'        => 'array',
						'description' => 'Admin destinations with title/url/icon/description.',
					),
				)
			),
			'execute_callback'    => static function ( $input ) {
				return openstation_ai_search_dispatch_tool( 'list_admin_pages', (array) $input );
			},
			'permission_callback' => 'openstation_ai_ability_can_read',
			'meta'                => $readonly_meta,
		)
	);

	wp_register_ability(
		'desktop-mode/search-wporg-plugins',
		array(
			'label'               => __( 'Search WordPress.org plugins', 'desktop-mode' ),
			'description'         => 'Searches the official WordPress.org plugin directory. Use this when the user asks for a plugin recommendation — e.g. "is there a plugin for SEO?", "find me a backup plugin", "a caching plugin", "form builder". Returns up to 10 plugins with name, description, rating, active install count, and an admin URL that opens the plugin-info / install screen directly.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => array(
				'type'                 => 'object',
				'additionalProperties' => false,
				'required'             => array( 'query' ),
				'properties'           => array(
					'query' => array(
						'type'        => 'string',
						'description' => 'Plain-language search terms — e.g. "seo", "backup", "caching", "woocommerce", "contact form".',
					),
				),
			),
			'output_schema'       => openstation_ai_ability_output_schema(
				array(
					'results' => array(
						'type'        => 'array',
						'description' => 'Matching plugins with name, description, rating, installs, and admin URL.',
					),
					'count'   => array( 'type' => 'integer' ),
				)
			),
			'execute_callback'    => static function ( $input ) {
				return openstation_ai_search_dispatch_tool( 'search_wporg_plugins', (array) $input );
			},
			'permission_callback' => 'openstation_ai_ability_can_read',
			'meta'                => $readonly_meta,
		)
	);

	wp_register_ability(
		'desktop-mode/get-php-error-log',
		array(
			'label'               => __( 'Read PHP error log', 'desktop-mode' ),
			'description'         => 'Reads the most recent entries from the site\'s PHP error log — typically wp-content/debug.log when WP_DEBUG_LOG is enabled, or the path set by the PHP error_log directive. Use this when the user asks "are there any errors?", "check the logs", "what went wrong?", or is troubleshooting a white screen / 500. Each entry is parsed into { timestamp, level, message } so you can summarise them. Administrators only.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => array(
				'type'                 => 'object',
				'additionalProperties' => false,
				'required'             => array( 'lines' ),
				'properties'           => array(
					'lines' => array(
						'type'        => 'integer',
						'description' => 'How many recent log lines to return (1-500). Use 20-50 for a quick look, 100-200 for wider context.',
					),
				),
			),
			'output_schema'       => openstation_ai_ability_output_schema(
				array(
					'log_available' => array( 'type' => 'boolean' ),
					'entries'       => array(
						'type'        => 'array',
						'description' => 'Parsed log lines: { timestamp, level, message }.',
					),
				)
			),
			'execute_callback'    => static function ( $input ) {
				return openstation_ai_search_dispatch_tool( 'get_php_error_log', (array) $input );
			},
			// Admin-only — mirrors the previous in-dispatcher manage_options gate.
			'permission_callback' => static function () {
				return current_user_can( 'manage_options' );
			},
			'meta'                => $readonly_private_meta,
		)
	);

	openstation_ai_register_comment_analysis_ability();
}
add_action( 'wp_abilities_api_init', 'openstation_ai_register_abilities' );

/**
 * Registers the comment-spam analysis ability.
 *
 * Not offered to the model during a search turn (see
 * {@see openstation_ai_search_ability_names()}); the moderation pipeline
 * resolves and executes it directly ({@see openstation_ai_analyze_comment_now()}
 * runs through it). Exposed in the abilities catalog for observability + reuse.
 *
 * @return void
 */
function openstation_ai_register_comment_analysis_ability() {
	wp_register_ability(
		'desktop-mode/analyze-comment',
		array(
			'label'               => __( 'Analyze comment for spam', 'desktop-mode' ),
			'description'         => 'Runs the AI spam/harm analysis for a single comment and returns its structured verdict ({ topic, ai_summary, harmful, spam }). Used by comment moderation to score incoming comments.',
			'category'            => OPENSTATION_AI_ABILITY_CATEGORY,
			'input_schema'        => array(
				'type'                 => 'object',
				'additionalProperties' => false,
				'required'             => array( 'comment_id' ),
				'properties'           => array(
					'comment_id' => array(
						'type'        => 'integer',
						'description' => 'The WordPress ID of the comment to analyze.',
					),
				),
			),
			'output_schema'       => openstation_ai_ability_output_schema(
				array(
					'topic'      => array( 'type' => 'string' ),
					'ai_summary' => array( 'type' => 'string' ),
					'harmful'    => array( 'type' => 'boolean' ),
					'spam'       => array( 'type' => 'boolean' ),
				)
			),
			'execute_callback'    => 'openstation_ai_ability_analyze_comment',
			'permission_callback' => static function () {
				return current_user_can( 'moderate_comments' );
			},
			'meta'                => array(
				'annotations'  => array(
					'readonly'   => true,
					'idempotent' => true,
				),
				'show_in_rest' => true,
			),
		)
	);
}

/**
 * Execute callback for the `desktop-mode/analyze-comment` ability.
 *
 * @param array<string,mixed> $input Validated input (`comment_id`).
 * @return array|WP_Error Structured verdict, or an error.
 */
function openstation_ai_ability_analyze_comment( $input ) {
	$comment_id = isset( $input['comment_id'] ) ? (int) $input['comment_id'] : 0;
	$comment    = $comment_id > 0 ? get_comment( $comment_id ) : null;
	if ( ! $comment instanceof WP_Comment ) {
		return new WP_Error( 'openstation_ai_comment_not_found', __( 'Comment not found.', 'desktop-mode' ) );
	}

	return openstation_ai_analyze_comment_now( $comment, (int) $comment->user_id );
}

```
