PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / ai-copilot / abilities.php

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

465 lines 19.1 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 * Permission callback: any logged-in user who can read the site.
109 *
110 * Mirrors the read-only search/navigation tools, which were ungated beyond the
111 * Copilot's own logged-in requirement.
112 *
113 * @return bool
114 */
115 function openstation_ai_ability_can_read() {
116 return is_user_logged_in() && current_user_can( 'read' );
117 }
118
119 /**
120 * A loose object output schema: typed at the top level, permissive on the rest
121 * so `WP_Ability::execute()`'s output validation never rejects a valid handler
122 * return (the shapes carry optional/nested fields we don't want to freeze).
123 *
124 * @param array<string,array<string,mixed>> $properties Documented top-level props.
125 * @return array<string,mixed>
126 */
127 function openstation_ai_ability_output_schema( array $properties = array() ) {
128 return array(
129 'type' => 'object',
130 'additionalProperties' => true,
131 // Keep as a plain (associative) array: WordPress's schema validator
132 // array-accesses `properties`, and every caller passes at least one
133 // property so JSON serialization is still an object. An empty-object
134 // `properties` would need a `(object)` cast, but we never emit one.
135 'properties' => $properties,
136 );
137 }
138
139 /**
140 * Registers every Copilot ability.
141 *
142 * @return void
143 */
144 function openstation_ai_register_abilities() {
145 if ( ! function_exists( 'wp_register_ability' ) ) {
146 return;
147 }
148
149 $query_offset_input = array(
150 'type' => 'object',
151 'additionalProperties' => false,
152 'required' => array( 'query', 'offset' ),
153 'properties' => array(
154 'query' => array(
155 'type' => 'string',
156 '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.',
157 ),
158 'offset' => array(
159 'type' => 'integer',
160 'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
161 ),
162 ),
163 );
164
165 $search_output = openstation_ai_ability_output_schema(
166 array(
167 'items' => array(
168 'type' => 'array',
169 'description' => 'Matching entities with identity, excerpt, and URLs.',
170 ),
171 'count' => array(
172 'type' => 'integer',
173 'description' => 'Number of items in this batch.',
174 ),
175 'total' => array(
176 'type' => 'integer',
177 'description' => 'Total matches across all batches.',
178 ),
179 'has_more' => array(
180 'type' => 'boolean',
181 'description' => 'Whether another batch is available at the next offset.',
182 ),
183 )
184 );
185
186 $readonly_meta = array(
187 'annotations' => array(
188 'readonly' => true,
189 'idempotent' => true,
190 ),
191 'show_in_rest' => true,
192 'mcp' => array(
193 'public' => true,
194 'type' => 'tool',
195 ),
196 );
197
198 // Admin-only abilities are still read-only, but must not be exposed to
199 // external agents over MCP.
200 $readonly_private_meta = array(
201 'annotations' => array(
202 'readonly' => true,
203 'idempotent' => true,
204 ),
205 'show_in_rest' => true,
206 );
207
208 wp_register_ability(
209 'desktop-mode/search-posts',
210 array(
211 'label' => __( 'Search posts', 'desktop-mode' ),
212 '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.',
213 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
214 'input_schema' => $query_offset_input,
215 'output_schema' => $search_output,
216 'execute_callback' => static function ( $input ) {
217 return openstation_ai_search_dispatch_tool( 'search_posts', (array) $input );
218 },
219 'permission_callback' => 'openstation_ai_ability_can_read',
220 'meta' => $readonly_meta,
221 )
222 );
223
224 wp_register_ability(
225 'desktop-mode/search-pages',
226 array(
227 'label' => __( 'Search pages', 'desktop-mode' ),
228 '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.',
229 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
230 'input_schema' => $query_offset_input,
231 'output_schema' => $search_output,
232 'execute_callback' => static function ( $input ) {
233 return openstation_ai_search_dispatch_tool( 'search_pages', (array) $input );
234 },
235 'permission_callback' => 'openstation_ai_ability_can_read',
236 'meta' => $readonly_meta,
237 )
238 );
239
240 wp_register_ability(
241 'desktop-mode/search-comments',
242 array(
243 'label' => __( 'Search comments', 'desktop-mode' ),
244 '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`. 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.',
245 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
246 'input_schema' => $query_offset_input,
247 'output_schema' => $search_output,
248 'execute_callback' => static function ( $input ) {
249 return openstation_ai_search_dispatch_tool( 'search_comments', (array) $input );
250 },
251 'permission_callback' => 'openstation_ai_ability_can_read',
252 'meta' => $readonly_meta,
253 )
254 );
255
256 wp_register_ability(
257 'desktop-mode/search-comments-by-post',
258 array(
259 'label' => __( 'Search comments on a post', 'desktop-mode' ),
260 '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 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.',
261 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
262 'input_schema' => array(
263 'type' => 'object',
264 'additionalProperties' => false,
265 'required' => array( 'post_id', 'query', 'offset' ),
266 'properties' => array(
267 'post_id' => array(
268 'type' => 'integer',
269 'description' => 'The WordPress ID of the post whose comments should be searched. Obtain this from a prior search-posts call.',
270 ),
271 'query' => array(
272 'type' => 'string',
273 '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.',
274 ),
275 'offset' => array(
276 'type' => 'integer',
277 'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.',
278 ),
279 ),
280 ),
281 'output_schema' => $search_output,
282 'execute_callback' => static function ( $input ) {
283 return openstation_ai_search_dispatch_tool( 'search_comments_by_post', (array) $input );
284 },
285 'permission_callback' => 'openstation_ai_ability_can_read',
286 'meta' => $readonly_meta,
287 )
288 );
289
290 wp_register_ability(
291 'desktop-mode/list-admin-pages',
292 array(
293 'label' => __( 'List admin pages', 'desktop-mode' ),
294 // Describes the TOOL, not the caller's answer format: agents
295 // and the Copilot consume the same registry, and the
296 // Copilot's `admin_links` / `answer_type` contract exists in
297 // its own system prompt and answer schema. Naming those
298 // fields here would instruct an agent to emit a shape its
299 // answer schema does not have.
300 '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.',
301 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
302 'input_schema' => array(
303 'type' => 'object',
304 'additionalProperties' => false,
305 'required' => array(),
306 'properties' => (object) array(),
307 ),
308 'output_schema' => openstation_ai_ability_output_schema(
309 array(
310 'pages' => array(
311 'type' => 'array',
312 'description' => 'Admin destinations with title/url/icon/description.',
313 ),
314 )
315 ),
316 'execute_callback' => static function ( $input ) {
317 return openstation_ai_search_dispatch_tool( 'list_admin_pages', (array) $input );
318 },
319 'permission_callback' => 'openstation_ai_ability_can_read',
320 'meta' => $readonly_meta,
321 )
322 );
323
324 wp_register_ability(
325 'desktop-mode/search-wporg-plugins',
326 array(
327 'label' => __( 'Search WordPress.org plugins', 'desktop-mode' ),
328 '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.',
329 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
330 'input_schema' => array(
331 'type' => 'object',
332 'additionalProperties' => false,
333 'required' => array( 'query' ),
334 'properties' => array(
335 'query' => array(
336 'type' => 'string',
337 'description' => 'Plain-language search terms — e.g. "seo", "backup", "caching", "woocommerce", "contact form".',
338 ),
339 ),
340 ),
341 'output_schema' => openstation_ai_ability_output_schema(
342 array(
343 'results' => array(
344 'type' => 'array',
345 'description' => 'Matching plugins with name, description, rating, installs, and admin URL.',
346 ),
347 'count' => array( 'type' => 'integer' ),
348 )
349 ),
350 'execute_callback' => static function ( $input ) {
351 return openstation_ai_search_dispatch_tool( 'search_wporg_plugins', (array) $input );
352 },
353 'permission_callback' => 'openstation_ai_ability_can_read',
354 'meta' => $readonly_meta,
355 )
356 );
357
358 wp_register_ability(
359 'desktop-mode/get-php-error-log',
360 array(
361 'label' => __( 'Read PHP error log', 'desktop-mode' ),
362 '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.',
363 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
364 'input_schema' => array(
365 'type' => 'object',
366 'additionalProperties' => false,
367 'required' => array( 'lines' ),
368 'properties' => array(
369 'lines' => array(
370 'type' => 'integer',
371 'description' => 'How many recent log lines to return (1-500). Use 20-50 for a quick look, 100-200 for wider context.',
372 ),
373 ),
374 ),
375 'output_schema' => openstation_ai_ability_output_schema(
376 array(
377 'log_available' => array( 'type' => 'boolean' ),
378 'entries' => array(
379 'type' => 'array',
380 'description' => 'Parsed log lines: { timestamp, level, message }.',
381 ),
382 )
383 ),
384 'execute_callback' => static function ( $input ) {
385 return openstation_ai_search_dispatch_tool( 'get_php_error_log', (array) $input );
386 },
387 // Admin-only — mirrors the previous in-dispatcher manage_options gate.
388 'permission_callback' => static function () {
389 return current_user_can( 'manage_options' );
390 },
391 'meta' => $readonly_private_meta,
392 )
393 );
394
395 openstation_ai_register_comment_analysis_ability();
396 }
397 add_action( 'wp_abilities_api_init', 'openstation_ai_register_abilities' );
398
399 /**
400 * Registers the comment-spam analysis ability.
401 *
402 * Not offered to the model during a search turn (see
403 * {@see openstation_ai_search_ability_names()}); the moderation pipeline
404 * resolves and executes it directly ({@see openstation_ai_analyze_comment_now()}
405 * runs through it). Exposed in the abilities catalog for observability + reuse.
406 *
407 * @return void
408 */
409 function openstation_ai_register_comment_analysis_ability() {
410 wp_register_ability(
411 'desktop-mode/analyze-comment',
412 array(
413 'label' => __( 'Analyze comment for spam', 'desktop-mode' ),
414 '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.',
415 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
416 'input_schema' => array(
417 'type' => 'object',
418 'additionalProperties' => false,
419 'required' => array( 'comment_id' ),
420 'properties' => array(
421 'comment_id' => array(
422 'type' => 'integer',
423 'description' => 'The WordPress ID of the comment to analyze.',
424 ),
425 ),
426 ),
427 'output_schema' => openstation_ai_ability_output_schema(
428 array(
429 'topic' => array( 'type' => 'string' ),
430 'ai_summary' => array( 'type' => 'string' ),
431 'harmful' => array( 'type' => 'boolean' ),
432 'spam' => array( 'type' => 'boolean' ),
433 )
434 ),
435 'execute_callback' => 'openstation_ai_ability_analyze_comment',
436 'permission_callback' => static function () {
437 return current_user_can( 'moderate_comments' );
438 },
439 'meta' => array(
440 'annotations' => array(
441 'readonly' => true,
442 'idempotent' => true,
443 ),
444 'show_in_rest' => true,
445 ),
446 )
447 );
448 }
449
450 /**
451 * Execute callback for the `desktop-mode/analyze-comment` ability.
452 *
453 * @param array<string,mixed> $input Validated input (`comment_id`).
454 * @return array|WP_Error Structured verdict, or an error.
455 */
456 function openstation_ai_ability_analyze_comment( $input ) {
457 $comment_id = isset( $input['comment_id'] ) ? (int) $input['comment_id'] : 0;
458 $comment = $comment_id > 0 ? get_comment( $comment_id ) : null;
459 if ( ! $comment instanceof WP_Comment ) {
460 return new WP_Error( 'openstation_ai_comment_not_found', __( 'Comment not found.', 'desktop-mode' ) );
461 }
462
463 return openstation_ai_analyze_comment_now( $comment, (int) $comment->user_id );
464 }
465