PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / includes / ai-copilot / abilities.php

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

513 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — 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 openstation_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 openstation_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 OpenStation
17 */
18
19 defined( 'ABSPATH' ) || exit;
20
21 /**
22 * Ability category slug shared by every Copilot ability.
23 */
24 const OPENSTATION_AI_ABILITY_CATEGORY = 'openstation';
25
26 /**
27 * Registers the `openstation` ability category.
28 *
29 * @return void
30 */
31 function openstation_ai_register_ability_category() {
32 if ( ! function_exists( 'wp_register_ability_category' ) ) {
33 return;
34 }
35
36 wp_register_ability_category(
37 OPENSTATION_AI_ABILITY_CATEGORY,
38 array(
39 'label' => __( 'OpenStation', 'desktop-mode' ),
40 'description' => __( 'Read-only content search and wp-admin navigation abilities powering the OpenStation AI assistant.', 'desktop-mode' ),
41 )
42 );
43 }
44 add_action( 'wp_abilities_api_categories_init', 'openstation_ai_register_ability_category' );
45
46 /**
47 * The ability names the Copilot offers the model as tools.
48 *
49 * Every registered ability marked read-only (`meta.annotations.readonly`) is
50 * offered — the Copilot's own search/navigation abilities, plus any read-only
51 * ability registered by Core or another plugin. No opt-in: register a
52 * read-only ability and the assistant can use it; its `permission_callback`
53 * still gates execution.
54 *
55 * Only read-only abilities are advertised on purpose: a search turn can be
56 * driven by attacker-controlled content (comment / post text that lands in a
57 * tool result), so the model is never handed an ability that could change the
58 * site.
59 *
60 * @return string[] Fully-namespaced ability names.
61 */
62 function openstation_ai_search_ability_names() {
63 if ( ! function_exists( 'wp_get_abilities' ) ) {
64 return array();
65 }
66
67 $names = array();
68 foreach ( wp_get_abilities() as $ability ) {
69 if ( ! $ability instanceof WP_Ability ) {
70 continue;
71 }
72 $meta = (array) $ability->get_meta();
73 $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : array();
74 if ( empty( $annotations['readonly'] ) ) {
75 continue;
76 }
77 $names[] = (string) $ability->get_name();
78 }
79
80 return $names;
81 }
82
83 /**
84 * The model-facing tool name for an ability — the ability name with its
85 * namespace stripped and dashes turned into underscores. By design this
86 * reproduces the Copilot's historical tool names (`desktop-mode/search-posts`
87 * → `search_posts`), so progress labels, the system prompt, and the answer
88 * schema keep referring to the same names across the abilities migration.
89 *
90 * @param string $ability_name Fully-namespaced ability name.
91 * @return string
92 */
93 function openstation_ai_ability_tool_name( $ability_name ) {
94 $slug = (string) $ability_name;
95 $pos = strpos( $slug, '/' );
96 if ( false !== $pos ) {
97 $slug = substr( $slug, $pos + 1 );
98 }
99 // This becomes the model-facing function name; most function-calling
100 // providers only accept [a-z0-9_], so normalize anything else (a
101 // third-party ability may carry extra slashes or mixed case).
102 $slug = strtolower( str_replace( '-', '_', $slug ) );
103 $slug = preg_replace( '/[^a-z0-9_]+/', '_', $slug );
104 return trim( (string) $slug, '_' );
105 }
106
107 /**
108 * Maps abilities to model-facing tool names that are unique within one
109 * request.
110 *
111 * Stripping the namespace means two plugins can land on the same name
112 * (`desktop-mode/get-site-context` and `acme/get-site-context`), and
113 * providers reject the whole request when tool names repeat ("Tool names
114 * must be unique"). OpenStation's own abilities claim their bare names
115 * first, because the system prompt and progress labels refer to those.
116 * Any later ability whose bare name is taken keeps its namespace
117 * (`acme_get_site_context`) so it stays callable; one that still
118 * collides is dropped.
119 *
120 * @param string[] $ability_names Fully-namespaced ability names.
121 * @return array<string,string> Tool name => ability name, own abilities first.
122 */
123 function openstation_ai_unique_ability_tool_names( array $ability_names ) {
124 $own = array();
125 $others = array();
126 foreach ( $ability_names as $ability_name ) {
127 $ability_name = (string) $ability_name;
128 if ( 0 === strpos( $ability_name, 'desktop-mode/' ) ) {
129 $own[] = $ability_name;
130 } else {
131 $others[] = $ability_name;
132 }
133 }
134
135 $map = array();
136 foreach ( array_merge( $own, $others ) as $ability_name ) {
137 $candidates = array(
138 openstation_ai_ability_tool_name( $ability_name ),
139 openstation_ai_ability_tool_name( str_replace( '/', '_', $ability_name ) ),
140 );
141 foreach ( $candidates as $tool_name ) {
142 if ( '' !== $tool_name && ! isset( $map[ $tool_name ] ) ) {
143 $map[ $tool_name ] = $ability_name;
144 break;
145 }
146 }
147 }
148
149 return $map;
150 }
151
152 /**
153 * Permission callback: any logged-in user who can read the site.
154 *
155 * Mirrors the read-only search/navigation tools, which were ungated beyond the
156 * Copilot's own logged-in requirement.
157 *
158 * @return bool
159 */
160 function openstation_ai_ability_can_read() {
161 return is_user_logged_in() && current_user_can( 'read' );
162 }
163
164 /**
165 * A loose object output schema: typed at the top level, permissive on the rest
166 * so `WP_Ability::execute()`'s output validation never rejects a valid handler
167 * return (the shapes carry optional/nested fields we don't want to freeze).
168 *
169 * @param array<string,array<string,mixed>> $properties Documented top-level props.
170 * @return array<string,mixed>
171 */
172 function openstation_ai_ability_output_schema( array $properties = array() ) {
173 return array(
174 'type' => 'object',
175 'additionalProperties' => true,
176 // Keep as a plain (associative) array: WordPress's schema validator
177 // array-accesses `properties`, and every caller passes at least one
178 // property so JSON serialization is still an object. An empty-object
179 // `properties` would need a `(object)` cast, but we never emit one.
180 'properties' => $properties,
181 );
182 }
183
184 /**
185 * Registers every Copilot ability.
186 *
187 * @return void
188 */
189 function openstation_ai_register_abilities() {
190 if ( ! function_exists( 'wp_register_ability' ) ) {
191 return;
192 }
193
194 $query_offset_input = array(
195 'type' => 'object',
196 'additionalProperties' => false,
197 'required' => array( 'query', 'offset' ),
198 'properties' => array(
199 'query' => array(
200 'type' => 'string',
201 '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. An empty string lists everything, newest first — only for questions about who, how many or the latest, never to look for a topic.',
202 ),
203 'offset' => array(
204 'type' => 'integer',
205 'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
206 ),
207 ),
208 );
209
210 $search_output = openstation_ai_ability_output_schema(
211 array(
212 'items' => array(
213 'type' => 'array',
214 'description' => 'Matching entities with identity, excerpt, and URLs.',
215 ),
216 'count' => array(
217 'type' => 'integer',
218 'description' => 'Number of items in this batch.',
219 ),
220 'total' => array(
221 'type' => 'integer',
222 'description' => 'Total matches across all batches.',
223 ),
224 'has_more' => array(
225 'type' => 'boolean',
226 'description' => 'Whether another batch is available at the next offset.',
227 ),
228 )
229 );
230
231 $readonly_meta = array(
232 'annotations' => array(
233 'readonly' => true,
234 'idempotent' => true,
235 ),
236 'show_in_rest' => true,
237 'mcp' => array(
238 'public' => true,
239 'type' => 'tool',
240 ),
241 );
242
243 // Admin-only abilities are still read-only, but must not be exposed to
244 // external agents over MCP.
245 $readonly_private_meta = array(
246 'annotations' => array(
247 'readonly' => true,
248 'idempotent' => true,
249 ),
250 'show_in_rest' => true,
251 );
252
253 wp_register_ability(
254 'desktop-mode/search-posts',
255 array(
256 'label' => __( 'Search posts', 'desktop-mode' ),
257 '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`. Only for listing or counting questions ("how many posts do I have?") pass an empty `query`, which lists the newest posts (`total` is the full count). 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.',
258 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
259 'input_schema' => $query_offset_input,
260 'output_schema' => $search_output,
261 'execute_callback' => static function ( $input ) {
262 return openstation_ai_search_dispatch_tool( 'search_posts', (array) $input );
263 },
264 'permission_callback' => 'openstation_ai_ability_can_read',
265 'meta' => $readonly_meta,
266 )
267 );
268
269 wp_register_ability(
270 'desktop-mode/search-pages',
271 array(
272 'label' => __( 'Search pages', 'desktop-mode' ),
273 '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.',
274 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
275 'input_schema' => $query_offset_input,
276 'output_schema' => $search_output,
277 'execute_callback' => static function ( $input ) {
278 return openstation_ai_search_dispatch_tool( 'search_pages', (array) $input );
279 },
280 'permission_callback' => 'openstation_ai_ability_can_read',
281 'meta' => $readonly_meta,
282 )
283 );
284
285 wp_register_ability(
286 'desktop-mode/search-comments',
287 array(
288 'label' => __( 'Search comments', 'desktop-mode' ),
289 'description' => 'Keyword-searches approved WordPress comments by their text (WordPress native search), across all posts the requesting user is allowed to read — comments on private, draft, or password-protected posts the user cannot access are excluded. 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`. Also use it, with an empty `query`, for questions about the comments themselves — who has commented, the latest comments, how many there are (`total` is the full count). Returns up to 10 matching comments with the commenter\'s name (`author_name`), an excerpt, parent post title, and URLs. If has_more is true, call again with the next offset.',
290 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
291 'input_schema' => $query_offset_input,
292 'output_schema' => $search_output,
293 'execute_callback' => static function ( $input ) {
294 return openstation_ai_search_dispatch_tool( 'search_comments', (array) $input );
295 },
296 'permission_callback' => 'openstation_ai_ability_can_read',
297 'meta' => $readonly_meta,
298 )
299 );
300
301 wp_register_ability(
302 'desktop-mode/search-comments-by-post',
303 array(
304 'label' => __( 'Search comments on a post', 'desktop-mode' ),
305 'description' => 'Keyword-searches approved comments on a SPECIFIC post by its WordPress ID (the post must be readable by the requesting user; an unreadable or nonexistent post returns an empty result). Use this when the user describes both a post and something a reader said on it: identify the post with search-posts, then pass its ID and the distinctive words of the comment as `query`. More precise than search-comments whenever the parent post is known. Each match carries the commenter\'s name (`author_name`) and an excerpt. If has_more is true, call again with the next offset.',
306 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
307 'input_schema' => array(
308 'type' => 'object',
309 'additionalProperties' => false,
310 'required' => array( 'post_id', 'query', 'offset' ),
311 'properties' => array(
312 'post_id' => array(
313 'type' => 'integer',
314 'description' => 'The WordPress ID of the post whose comments should be searched. Obtain this from a prior search-posts call.',
315 ),
316 'query' => array(
317 'type' => 'string',
318 '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.',
319 ),
320 'offset' => array(
321 'type' => 'integer',
322 'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
323 ),
324 ),
325 ),
326 'output_schema' => $search_output,
327 'execute_callback' => static function ( $input ) {
328 return openstation_ai_search_dispatch_tool( 'search_comments_by_post', (array) $input );
329 },
330 'permission_callback' => 'openstation_ai_ability_can_read',
331 'meta' => $readonly_meta,
332 )
333 );
334
335 wp_register_ability(
336 'desktop-mode/list-admin-pages',
337 array(
338 'label' => __( 'List admin pages', 'desktop-mode' ),
339 // Describes the TOOL, not the caller's answer format: agents
340 // and the Copilot consume the same registry, and the
341 // Copilot's `admin_links` / `answer_type` contract exists in
342 // its own system prompt and answer schema. Naming those
343 // fields here would instruct an agent to emit a shape its
344 // answer schema does not have.
345 '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.',
346 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
347 'input_schema' => array(
348 'type' => 'object',
349 'additionalProperties' => false,
350 'required' => array(),
351 'properties' => (object) array(),
352 ),
353 'output_schema' => openstation_ai_ability_output_schema(
354 array(
355 'pages' => array(
356 'type' => 'array',
357 'description' => 'Admin destinations with title/url/icon/description.',
358 ),
359 )
360 ),
361 'execute_callback' => static function ( $input ) {
362 return openstation_ai_search_dispatch_tool( 'list_admin_pages', (array) $input );
363 },
364 'permission_callback' => 'openstation_ai_ability_can_read',
365 'meta' => $readonly_meta,
366 )
367 );
368
369 wp_register_ability(
370 'desktop-mode/search-wporg-plugins',
371 array(
372 'label' => __( 'Search WordPress.org plugins', 'desktop-mode' ),
373 '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 (0-100) and stars (0-5), active install count, and an admin URL that opens the plugin-info / install screen directly.',
374 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
375 'input_schema' => array(
376 'type' => 'object',
377 'additionalProperties' => false,
378 'required' => array( 'query' ),
379 'properties' => array(
380 'query' => array(
381 'type' => 'string',
382 'description' => 'Plain-language search terms — e.g. "seo", "backup", "caching", "woocommerce", "contact form".',
383 ),
384 ),
385 ),
386 'output_schema' => openstation_ai_ability_output_schema(
387 array(
388 'results' => array(
389 'type' => 'array',
390 'description' => 'Matching plugins with name, description, rating (0-100), stars (0-5), installs, and admin URL.',
391 ),
392 'count' => array( 'type' => 'integer' ),
393 )
394 ),
395 'execute_callback' => static function ( $input ) {
396 return openstation_ai_search_dispatch_tool( 'search_wporg_plugins', (array) $input );
397 },
398 'permission_callback' => 'openstation_ai_ability_can_read',
399 'meta' => $readonly_meta,
400 )
401 );
402
403 wp_register_ability(
404 'desktop-mode/get-php-error-log',
405 array(
406 'label' => __( 'Read PHP error log', 'desktop-mode' ),
407 '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.',
408 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
409 'input_schema' => array(
410 'type' => 'object',
411 'additionalProperties' => false,
412 'required' => array( 'lines' ),
413 'properties' => array(
414 'lines' => array(
415 'type' => 'integer',
416 'description' => 'How many recent log lines to return (1-500). Use 20-50 for a quick look, 100-200 for wider context.',
417 ),
418 ),
419 ),
420 'output_schema' => openstation_ai_ability_output_schema(
421 array(
422 'log_available' => array( 'type' => 'boolean' ),
423 'entries' => array(
424 'type' => 'array',
425 'description' => 'Parsed log lines: { timestamp, level, message }.',
426 ),
427 )
428 ),
429 'execute_callback' => static function ( $input ) {
430 return openstation_ai_search_dispatch_tool( 'get_php_error_log', (array) $input );
431 },
432 // Admin-only — mirrors the previous in-dispatcher manage_options gate.
433 'permission_callback' => static function () {
434 return current_user_can( 'manage_options' );
435 },
436 'meta' => $readonly_private_meta,
437 )
438 );
439
440 openstation_ai_register_comment_analysis_ability();
441 }
442 add_action( 'wp_abilities_api_init', 'openstation_ai_register_abilities' );
443
444 /**
445 * Registers the comment-spam analysis ability.
446 *
447 * Not offered to the model during a search turn (see
448 * {@see openstation_ai_search_ability_names()}) — a search turn can be driven
449 * by attacker-controlled comment text, and this one spends provider tokens.
450 * It is an on-demand ability: a caller with `moderate_comments` runs it for one
451 * comment and gets the verdict back. Automatic scoring on comment save was
452 * removed (`docs/migration-comments-ai-scoring.md`), so nothing in the plugin
453 * invokes it on its own.
454 *
455 * @return void
456 */
457 function openstation_ai_register_comment_analysis_ability() {
458 wp_register_ability(
459 'desktop-mode/analyze-comment',
460 array(
461 'label' => __( 'Analyze comment for spam', 'desktop-mode' ),
462 'description' => 'Runs the AI spam/harm analysis for a single comment and returns its structured verdict ({ topic, ai_summary, harmful, spam }). On-demand only: nothing in the plugin runs it automatically.',
463 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
464 'input_schema' => array(
465 'type' => 'object',
466 'additionalProperties' => false,
467 'required' => array( 'comment_id' ),
468 'properties' => array(
469 'comment_id' => array(
470 'type' => 'integer',
471 'description' => 'The WordPress ID of the comment to analyze.',
472 ),
473 ),
474 ),
475 'output_schema' => openstation_ai_ability_output_schema(
476 array(
477 'topic' => array( 'type' => 'string' ),
478 'ai_summary' => array( 'type' => 'string' ),
479 'harmful' => array( 'type' => 'boolean' ),
480 'spam' => array( 'type' => 'boolean' ),
481 )
482 ),
483 'execute_callback' => 'openstation_ai_ability_analyze_comment',
484 'permission_callback' => static function () {
485 return current_user_can( 'moderate_comments' );
486 },
487 'meta' => array(
488 'annotations' => array(
489 'readonly' => true,
490 'idempotent' => true,
491 ),
492 'show_in_rest' => true,
493 ),
494 )
495 );
496 }
497
498 /**
499 * Execute callback for the `desktop-mode/analyze-comment` ability.
500 *
501 * @param array<string,mixed> $input Validated input (`comment_id`).
502 * @return array|WP_Error Structured verdict, or an error.
503 */
504 function openstation_ai_ability_analyze_comment( $input ) {
505 $comment_id = isset( $input['comment_id'] ) ? (int) $input['comment_id'] : 0;
506 $comment = $comment_id > 0 ? get_comment( $comment_id ) : null;
507 if ( ! $comment instanceof WP_Comment ) {
508 return new WP_Error( 'openstation_ai_comment_not_found', __( 'Comment not found.', 'desktop-mode' ) );
509 }
510
511 return openstation_ai_analyze_comment_now( $comment, (int) $comment->user_id );
512 }
513