PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / ai-copilot / abilities.php

abilities.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.7, at includes/ai-copilot/abilities.php

441 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — AI Copilot abilities.
4 *
5 * The Copilot's tools are WordPress Abilities API abilities: the agent loop
6 * offers the model every registered read-only ability (see
7 * {@see desktop_mode_ai_search_ability_names()}) and runs a chosen one through
8 * `wp_get_ability()->execute()` — permission checks and input validation
9 * happen inside `WP_Ability::execute()`.
10 *
11 * Every ability's `execute_callback` delegates to the existing query handlers
12 * (via {@see desktop_mode_ai_search_dispatch_tool()} / the comment scorer) so
13 * there is a single implementation of each tool. The ability is the source of
14 * truth for the model-facing description + input schema.
15 *
16 * @package WPDesktopMode
17 */
18
19 defined( 'ABSPATH' ) || exit;
20
21 /**
22 * Ability category slug shared by every Copilot ability.
23 */
24 const DESKTOP_MODE_AI_ABILITY_CATEGORY = 'desktop-mode';
25
26 /**
27 * Registers the `desktop-mode` ability category.
28 *
29 * @since 0.9.4
30 *
31 * @return void
32 */
33 function desktop_mode_ai_register_ability_category() {
34 if ( ! function_exists( 'wp_register_ability_category' ) ) {
35 return;
36 }
37
38 wp_register_ability_category(
39 DESKTOP_MODE_AI_ABILITY_CATEGORY,
40 array(
41 'label' => __( 'Desktop Mode', 'desktop-mode' ),
42 'description' => __( 'Read-only content search and wp-admin navigation abilities powering the Desktop Mode AI assistant.', 'desktop-mode' ),
43 )
44 );
45 }
46 add_action( 'wp_abilities_api_categories_init', 'desktop_mode_ai_register_ability_category' );
47
48 /**
49 * The ability names the Copilot offers the model as tools.
50 *
51 * Every registered ability marked read-only (`meta.annotations.readonly`) is
52 * offered — the Copilot's own search/navigation abilities, plus any read-only
53 * ability registered by Core or another plugin. No opt-in: register a
54 * read-only ability and the assistant can use it; its `permission_callback`
55 * still gates execution.
56 *
57 * Only read-only abilities are advertised on purpose: a search turn can be
58 * driven by attacker-controlled content (comment / post text that lands in a
59 * tool result), so the model is never handed an ability that could change the
60 * site.
61 *
62 * @since 0.9.4
63 *
64 * @return string[] Fully-namespaced ability names.
65 */
66 function desktop_mode_ai_search_ability_names() {
67 if ( ! function_exists( 'wp_get_abilities' ) ) {
68 return array();
69 }
70
71 $names = array();
72 foreach ( wp_get_abilities() as $ability ) {
73 if ( ! $ability instanceof WP_Ability ) {
74 continue;
75 }
76 $meta = (array) $ability->get_meta();
77 $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : array();
78 if ( empty( $annotations['readonly'] ) ) {
79 continue;
80 }
81 $names[] = (string) $ability->get_name();
82 }
83
84 return $names;
85 }
86
87 /**
88 * The model-facing tool name for an ability — the ability name with its
89 * namespace stripped and dashes turned into underscores. By design this
90 * reproduces the Copilot's historical tool names (`desktop-mode/search-posts`
91 * → `search_posts`), so progress labels, the system prompt, and the answer
92 * schema keep referring to the same names across the abilities migration.
93 *
94 * @since 0.9.4
95 *
96 * @param string $ability_name Fully-namespaced ability name.
97 * @return string
98 */
99 function desktop_mode_ai_ability_tool_name( $ability_name ) {
100 $slug = (string) $ability_name;
101 $pos = strpos( $slug, '/' );
102 if ( false !== $pos ) {
103 $slug = substr( $slug, $pos + 1 );
104 }
105 // This becomes the model-facing function name; most function-calling
106 // providers only accept [a-z0-9_], so normalize anything else (a
107 // third-party ability may carry extra slashes or mixed case).
108 $slug = strtolower( str_replace( '-', '_', $slug ) );
109 $slug = preg_replace( '/[^a-z0-9_]+/', '_', $slug );
110 return trim( (string) $slug, '_' );
111 }
112
113 /**
114 * Permission callback: any logged-in user who can read the site.
115 *
116 * Mirrors the read-only search/navigation tools, which were ungated beyond the
117 * Copilot's own logged-in requirement.
118 *
119 * @since 0.9.4
120 *
121 * @return bool
122 */
123 function desktop_mode_ai_ability_can_read() {
124 return is_user_logged_in() && current_user_can( 'read' );
125 }
126
127 /**
128 * A loose object output schema: typed at the top level, permissive on the rest
129 * so `WP_Ability::execute()`'s output validation never rejects a valid handler
130 * return (the shapes carry optional/nested fields we don't want to freeze).
131 *
132 * @since 0.9.4
133 *
134 * @param array<string,array<string,mixed>> $properties Documented top-level props.
135 * @return array<string,mixed>
136 */
137 function desktop_mode_ai_ability_output_schema( array $properties = array() ) {
138 return array(
139 'type' => 'object',
140 'additionalProperties' => true,
141 // Keep as a plain (associative) array: WordPress's schema validator
142 // array-accesses `properties`, and every caller passes at least one
143 // property so JSON serialization is still an object. An empty-object
144 // `properties` would need a `(object)` cast, but we never emit one.
145 'properties' => $properties,
146 );
147 }
148
149 /**
150 * Registers every Copilot ability.
151 *
152 * @since 0.9.4
153 *
154 * @return void
155 */
156 function desktop_mode_ai_register_abilities() {
157 if ( ! function_exists( 'wp_register_ability' ) ) {
158 return;
159 }
160
161 $query_offset_input = array(
162 'type' => 'object',
163 'additionalProperties' => false,
164 'required' => array( 'query', 'offset' ),
165 'properties' => array(
166 'query' => array(
167 'type' => 'string',
168 '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.',
169 ),
170 'offset' => array(
171 'type' => 'integer',
172 'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
173 ),
174 ),
175 );
176
177 $search_output = desktop_mode_ai_ability_output_schema(
178 array(
179 'items' => array( 'type' => 'array', 'description' => 'Matching entities with identity, excerpt, and URLs.' ),
180 'count' => array( 'type' => 'integer', 'description' => 'Number of items in this batch.' ),
181 'total' => array( 'type' => 'integer', 'description' => 'Total matches across all batches.' ),
182 'has_more' => array( 'type' => 'boolean', 'description' => 'Whether another batch is available at the next offset.' ),
183 )
184 );
185
186 $readonly_meta = array(
187 'annotations' => array( 'readonly' => true, 'idempotent' => true ),
188 'show_in_rest' => true,
189 'mcp' => array( 'public' => true, 'type' => 'tool' ),
190 );
191
192 // Admin-only abilities are still read-only, but must not be exposed to
193 // external agents over MCP.
194 $readonly_private_meta = array(
195 'annotations' => array( 'readonly' => true, 'idempotent' => true ),
196 'show_in_rest' => true,
197 );
198
199 wp_register_ability(
200 'desktop-mode/search-posts',
201 array(
202 'label' => __( 'Search posts', 'desktop-mode' ),
203 '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.',
204 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
205 'input_schema' => $query_offset_input,
206 'output_schema' => $search_output,
207 'execute_callback' => static function ( $input ) {
208 return desktop_mode_ai_search_dispatch_tool( 'search_posts', (array) $input );
209 },
210 'permission_callback' => 'desktop_mode_ai_ability_can_read',
211 'meta' => $readonly_meta,
212 )
213 );
214
215 wp_register_ability(
216 'desktop-mode/search-pages',
217 array(
218 'label' => __( 'Search pages', 'desktop-mode' ),
219 '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.',
220 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
221 'input_schema' => $query_offset_input,
222 'output_schema' => $search_output,
223 'execute_callback' => static function ( $input ) {
224 return desktop_mode_ai_search_dispatch_tool( 'search_pages', (array) $input );
225 },
226 'permission_callback' => 'desktop_mode_ai_ability_can_read',
227 'meta' => $readonly_meta,
228 )
229 );
230
231 wp_register_ability(
232 'desktop-mode/search-comments',
233 array(
234 'label' => __( 'Search comments', 'desktop-mode' ),
235 '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.',
236 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
237 'input_schema' => $query_offset_input,
238 'output_schema' => $search_output,
239 'execute_callback' => static function ( $input ) {
240 return desktop_mode_ai_search_dispatch_tool( 'search_comments', (array) $input );
241 },
242 'permission_callback' => 'desktop_mode_ai_ability_can_read',
243 'meta' => $readonly_meta,
244 )
245 );
246
247 wp_register_ability(
248 'desktop-mode/search-comments-by-post',
249 array(
250 'label' => __( 'Search comments on a post', 'desktop-mode' ),
251 '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.',
252 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
253 'input_schema' => array(
254 'type' => 'object',
255 'additionalProperties' => false,
256 'required' => array( 'post_id', 'query', 'offset' ),
257 'properties' => array(
258 'post_id' => array(
259 'type' => 'integer',
260 'description' => 'The WordPress ID of the post whose comments should be searched. Obtain this from a prior search-posts call.',
261 ),
262 'query' => array(
263 'type' => 'string',
264 '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.',
265 ),
266 'offset' => array(
267 'type' => 'integer',
268 'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
269 ),
270 ),
271 ),
272 'output_schema' => $search_output,
273 'execute_callback' => static function ( $input ) {
274 return desktop_mode_ai_search_dispatch_tool( 'search_comments_by_post', (array) $input );
275 },
276 'permission_callback' => 'desktop_mode_ai_ability_can_read',
277 'meta' => $readonly_meta,
278 )
279 );
280
281 wp_register_ability(
282 'desktop-mode/list-admin-pages',
283 array(
284 'label' => __( 'List admin pages', 'desktop-mode' ),
285 '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. Once you have the catalog, select the 1-3 most relevant entries for the user\'s query and include them in your answer under admin_links with answer_type="navigation". The catalog is small and stable so one call is enough.',
286 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
287 'input_schema' => array(
288 'type' => 'object',
289 'additionalProperties' => false,
290 'required' => array(),
291 'properties' => (object) array(),
292 ),
293 'output_schema' => desktop_mode_ai_ability_output_schema(
294 array( 'pages' => array( 'type' => 'array', 'description' => 'Admin destinations with title/url/icon/description.' ) )
295 ),
296 'execute_callback' => static function ( $input ) {
297 return desktop_mode_ai_search_dispatch_tool( 'list_admin_pages', (array) $input );
298 },
299 'permission_callback' => 'desktop_mode_ai_ability_can_read',
300 'meta' => $readonly_meta,
301 )
302 );
303
304 wp_register_ability(
305 'desktop-mode/search-wporg-plugins',
306 array(
307 'label' => __( 'Search WordPress.org plugins', 'desktop-mode' ),
308 '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. Present the results as admin_links with answer_type="navigation", titled "Plugin Name · 5M+ installs · 4.8�
309 ".',
310 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
311 'input_schema' => array(
312 'type' => 'object',
313 'additionalProperties' => false,
314 'required' => array( 'query' ),
315 'properties' => array(
316 'query' => array(
317 'type' => 'string',
318 'description' => 'Plain-language search terms — e.g. "seo", "backup", "caching", "woocommerce", "contact form".',
319 ),
320 ),
321 ),
322 'output_schema' => desktop_mode_ai_ability_output_schema(
323 array(
324 'results' => array( 'type' => 'array', 'description' => 'Matching plugins with name, description, rating, installs, and admin URL.' ),
325 'count' => array( 'type' => 'integer' ),
326 )
327 ),
328 'execute_callback' => static function ( $input ) {
329 return desktop_mode_ai_search_dispatch_tool( 'search_wporg_plugins', (array) $input );
330 },
331 'permission_callback' => 'desktop_mode_ai_ability_can_read',
332 'meta' => $readonly_meta,
333 )
334 );
335
336 wp_register_ability(
337 'desktop-mode/get-php-error-log',
338 array(
339 'label' => __( 'Read PHP error log', 'desktop-mode' ),
340 '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.',
341 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
342 'input_schema' => array(
343 'type' => 'object',
344 'additionalProperties' => false,
345 'required' => array( 'lines' ),
346 'properties' => array(
347 'lines' => array(
348 'type' => 'integer',
349 'description' => 'How many recent log lines to return (1-500). Use 20-50 for a quick look, 100-200 for wider context.',
350 ),
351 ),
352 ),
353 'output_schema' => desktop_mode_ai_ability_output_schema(
354 array(
355 'log_available' => array( 'type' => 'boolean' ),
356 'entries' => array( 'type' => 'array', 'description' => 'Parsed log lines: { timestamp, level, message }.' ),
357 )
358 ),
359 'execute_callback' => static function ( $input ) {
360 return desktop_mode_ai_search_dispatch_tool( 'get_php_error_log', (array) $input );
361 },
362 // Admin-only — mirrors the previous in-dispatcher manage_options gate.
363 'permission_callback' => static function () {
364 return current_user_can( 'manage_options' );
365 },
366 'meta' => $readonly_private_meta,
367 )
368 );
369
370 desktop_mode_ai_register_comment_analysis_ability();
371 }
372 add_action( 'wp_abilities_api_init', 'desktop_mode_ai_register_abilities' );
373
374 /**
375 * Registers the comment-spam analysis ability.
376 *
377 * Not offered to the model during a search turn (see
378 * {@see desktop_mode_ai_search_ability_names()}); the moderation pipeline
379 * resolves and executes it directly ({@see desktop_mode_ai_analyze_comment_now()}
380 * runs through it). Exposed in the abilities catalog for observability + reuse.
381 *
382 * @since 0.9.4
383 *
384 * @return void
385 */
386 function desktop_mode_ai_register_comment_analysis_ability() {
387 wp_register_ability(
388 'desktop-mode/analyze-comment',
389 array(
390 'label' => __( 'Analyze comment for spam', 'desktop-mode' ),
391 '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.',
392 'category' => DESKTOP_MODE_AI_ABILITY_CATEGORY,
393 'input_schema' => array(
394 'type' => 'object',
395 'additionalProperties' => false,
396 'required' => array( 'comment_id' ),
397 'properties' => array(
398 'comment_id' => array(
399 'type' => 'integer',
400 'description' => 'The WordPress ID of the comment to analyze.',
401 ),
402 ),
403 ),
404 'output_schema' => desktop_mode_ai_ability_output_schema(
405 array(
406 'topic' => array( 'type' => 'string' ),
407 'ai_summary' => array( 'type' => 'string' ),
408 'harmful' => array( 'type' => 'boolean' ),
409 'spam' => array( 'type' => 'boolean' ),
410 )
411 ),
412 'execute_callback' => 'desktop_mode_ai_ability_analyze_comment',
413 'permission_callback' => static function () {
414 return current_user_can( 'moderate_comments' );
415 },
416 'meta' => array(
417 'annotations' => array( 'readonly' => true, 'idempotent' => true ),
418 'show_in_rest' => true,
419 ),
420 )
421 );
422 }
423
424 /**
425 * Execute callback for the `desktop-mode/analyze-comment` ability.
426 *
427 * @since 0.9.4
428 *
429 * @param array<string,mixed> $input Validated input (`comment_id`).
430 * @return array|WP_Error Structured verdict, or an error.
431 */
432 function desktop_mode_ai_ability_analyze_comment( $input ) {
433 $comment_id = isset( $input['comment_id'] ) ? (int) $input['comment_id'] : 0;
434 $comment = $comment_id > 0 ? get_comment( $comment_id ) : null;
435 if ( ! $comment instanceof WP_Comment ) {
436 return new WP_Error( 'desktop_mode_ai_comment_not_found', __( 'Comment not found.', 'desktop-mode' ) );
437 }
438
439 return desktop_mode_ai_analyze_comment_now( $comment, (int) $comment->user_id );
440 }
441