PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
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 0.8.9 All 36 releases
desktop-mode / includes / agents / abilities.php

abilities.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.12, at includes/agents/abilities.php

672 lines 23.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: abilities bridge.
4 *
5 * Two halves:
6 *
7 * 1. Registers the agent-oriented abilities against Core's Abilities
8 * API: `desktop-mode/get-post` and `desktop-mode/get-media`
9 * (read-only) plus the mutating trio `desktop-mode/update-post`,
10 * `desktop-mode/update-media` (alt text / title / caption /
11 * description), and `desktop-mode/create-post` (draft-only). The
12 * `openstation` category ships from the AI Copilot module
13 * (always loaded), so this file only adds abilities to it. The
14 * read abilities carry the `readonly` annotation and therefore
15 * also become available to the AI Copilot assistant; the mutating
16 * ones do not — they are reachable only through an agent whose
17 * allowlist includes them.
18 *
19 * 2. Provides the abilities catalogue the picker UI consumes: every
20 * ability registered on the site, projected to
21 * `{ slug, label, description, category, readonly }`. Unlike the
22 * Copilot (which advertises only read-only abilities), agents may
23 * be granted mutating abilities — that is the point. The
24 * compensating controls are the explicit per-agent allowlist set by
25 * an `edit_users` human, the agent's role, and each ability's own
26 * `permission_callback` evaluated against the agent user.
27 *
28 * @package OpenStation
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 /**
34 * Registers the agent-oriented abilities.
35 *
36 * @return void
37 */
38 function openstation_agents_register_abilities() {
39 if ( ! function_exists( 'wp_register_ability' ) ) {
40 return;
41 }
42
43 wp_register_ability(
44 'desktop-mode/get-post',
45 array(
46 'label' => __( 'Get post by id', 'desktop-mode' ),
47 // The rawness of `content` is load-bearing for any agent that
48 // edits posts, and it belongs here rather than in a prompt:
49 // stated once on the ability, every agent's generated tool
50 // manifest carries it. Saying it only in an agent's own
51 // instructions leaves every other agent guessing, and a
52 // cautious one will refuse to write rather than risk
53 // flattening blocks.
54 'description' => 'Return a post — title, content, excerpt, status, author, dates — by its numeric id. `content` is the RAW stored content exactly as saved, with block delimiter comments (`<!-- wp:… -->`) intact; it is never rendered output, so it is safe to edit and write back. Honours the caller\'s read capability.',
55 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
56 'input_schema' => array(
57 'type' => 'object',
58 'additionalProperties' => false,
59 'required' => array( 'post_id' ),
60 'properties' => array(
61 'post_id' => array(
62 'type' => 'integer',
63 'description' => 'The post id to fetch.',
64 ),
65 ),
66 ),
67 'output_schema' => openstation_ai_ability_output_schema(
68 array(
69 'id' => array( 'type' => 'integer' ),
70 'title' => array( 'type' => 'string' ),
71 'content' => array( 'type' => 'string' ),
72 'status' => array( 'type' => 'string' ),
73 )
74 ),
75 'execute_callback' => 'openstation_agents_ability_get_post',
76 'permission_callback' => 'openstation_agents_ability_get_post_can',
77 'meta' => array(
78 'annotations' => array(
79 'readonly' => true,
80 'idempotent' => true,
81 ),
82 'show_in_rest' => true,
83 ),
84 )
85 );
86
87 wp_register_ability(
88 'desktop-mode/get-media',
89 array(
90 'label' => __( 'Get media details', 'desktop-mode' ),
91 'description' => 'Return details for a media library item (attachment) by numeric id: file URL, mime type, dimensions, alt text, caption, and the post it is attached to. Use this to read images or other media referenced by posts.',
92 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
93 'input_schema' => array(
94 'type' => 'object',
95 'additionalProperties' => false,
96 'required' => array( 'attachment_id' ),
97 'properties' => array(
98 'attachment_id' => array(
99 'type' => 'integer',
100 'description' => 'The attachment (media library) id.',
101 ),
102 ),
103 ),
104 'output_schema' => openstation_ai_ability_output_schema(
105 array(
106 'id' => array( 'type' => 'integer' ),
107 'url' => array( 'type' => 'string' ),
108 'mime' => array( 'type' => 'string' ),
109 )
110 ),
111 'execute_callback' => 'openstation_agents_ability_get_media',
112 'permission_callback' => 'openstation_agents_ability_get_media_can',
113 'meta' => array(
114 'annotations' => array(
115 'readonly' => true,
116 'idempotent' => true,
117 ),
118 'show_in_rest' => true,
119 ),
120 )
121 );
122
123 wp_register_ability(
124 'desktop-mode/update-media',
125 array(
126 'label' => __( 'Update media details', 'desktop-mode' ),
127 'description' => 'Update metadata on a media library item (attachment): alt text, title, caption, and/or description. The file itself is never touched. Honours the edit capability on the attachment.',
128 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
129 'input_schema' => array(
130 'type' => 'object',
131 'additionalProperties' => false,
132 'required' => array( 'attachment_id' ),
133 'properties' => array(
134 'attachment_id' => array(
135 'type' => 'integer',
136 'description' => 'The attachment (media library) id.',
137 ),
138 'alt_text' => array(
139 'type' => 'string',
140 'description' => 'New alternative text for the image (plain text, describing what the image shows).',
141 ),
142 'title' => array(
143 'type' => 'string',
144 'description' => 'New attachment title.',
145 ),
146 'caption' => array(
147 'type' => 'string',
148 'description' => 'New caption.',
149 ),
150 'description' => array(
151 'type' => 'string',
152 'description' => 'New description.',
153 ),
154 ),
155 ),
156 'output_schema' => openstation_ai_ability_output_schema(
157 array(
158 'id' => array( 'type' => 'integer' ),
159 'updated' => array( 'type' => 'boolean' ),
160 )
161 ),
162 'execute_callback' => 'openstation_agents_ability_update_media',
163 'permission_callback' => 'openstation_agents_ability_update_media_can',
164 'meta' => array(
165 'show_in_rest' => true,
166 ),
167 )
168 );
169
170 wp_register_ability(
171 'desktop-mode/create-post',
172 array(
173 'label' => __( 'Create draft post', 'desktop-mode' ),
174 'description' => 'Create a NEW post or page as a DRAFT, authored by the calling user. The status is always draft: this ability can never publish. Use it to produce reviewable content (translations, variants, generated drafts) without touching any existing post. `content` is stored RAW, exactly as passed, so send block markup with its delimiter comments (`<!-- wp:… -->`) intact.',
175 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
176 'input_schema' => array(
177 'type' => 'object',
178 'additionalProperties' => false,
179 'required' => array( 'title', 'content' ),
180 'properties' => array(
181 'title' => array(
182 'type' => 'string',
183 'description' => 'Post title.',
184 ),
185 'content' => array(
186 'type' => 'string',
187 'description' => 'Post content (HTML / block markup).',
188 ),
189 'excerpt' => array(
190 'type' => 'string',
191 'description' => 'Optional excerpt.',
192 ),
193 'type' => array(
194 'type' => 'string',
195 'enum' => array( 'post', 'page' ),
196 'description' => 'Post type. Defaults to post.',
197 ),
198 ),
199 ),
200 'output_schema' => openstation_ai_ability_output_schema(
201 array(
202 'id' => array( 'type' => 'integer' ),
203 'status' => array( 'type' => 'string' ),
204 )
205 ),
206 'execute_callback' => 'openstation_agents_ability_create_post',
207 'permission_callback' => 'openstation_agents_ability_create_post_can',
208 'meta' => array(
209 'show_in_rest' => true,
210 ),
211 )
212 );
213
214 wp_register_ability(
215 'desktop-mode/update-post',
216 array(
217 'label' => __( 'Update post', 'desktop-mode' ),
218 'description' => 'Update fields on an existing post. Accepts any subset of title / content / excerpt / status. `content` is stored RAW, exactly as passed, so send block markup with its delimiter comments (`<!-- wp:… -->`) intact — passing rendered HTML would flatten the post\'s blocks. Honours the edit_post capability of the calling user.',
219 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
220 'input_schema' => array(
221 'type' => 'object',
222 'additionalProperties' => false,
223 'required' => array( 'post_id' ),
224 'properties' => array(
225 'post_id' => array(
226 'type' => 'integer',
227 'description' => 'The post id to update.',
228 ),
229 'title' => array(
230 'type' => 'string',
231 'description' => 'New post title.',
232 ),
233 'content' => array(
234 'type' => 'string',
235 'description' => 'New post content (HTML / block markup).',
236 ),
237 'excerpt' => array(
238 'type' => 'string',
239 'description' => 'New post excerpt.',
240 ),
241 'status' => array(
242 'type' => 'string',
243 'enum' => array( 'publish', 'draft', 'pending', 'private' ),
244 'description' => 'New post status.',
245 ),
246 ),
247 ),
248 'output_schema' => openstation_ai_ability_output_schema(
249 array(
250 'id' => array( 'type' => 'integer' ),
251 'updated' => array( 'type' => 'boolean' ),
252 )
253 ),
254 'execute_callback' => 'openstation_agents_ability_update_post',
255 'permission_callback' => 'openstation_agents_ability_update_post_can',
256 'meta' => array(
257 'show_in_rest' => true,
258 ),
259 )
260 );
261 }
262 add_action( 'wp_abilities_api_init', 'openstation_agents_register_abilities' );
263
264 /**
265 * `desktop-mode/get-post` execute callback.
266 *
267 * @param array $args Validated input.
268 * @return array|WP_Error
269 */
270 function openstation_agents_ability_get_post( $args ) {
271 $args = (array) $args;
272 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
273 $post = $post_id > 0 ? get_post( $post_id ) : null;
274 if ( ! ( $post instanceof WP_Post ) ) {
275 return new WP_Error( 'openstation_agent_post_not_found', __( 'Post not found.', 'desktop-mode' ) );
276 }
277 return array(
278 'id' => (int) $post->ID,
279 'title' => (string) $post->post_title,
280 'content' => (string) $post->post_content,
281 'excerpt' => (string) $post->post_excerpt,
282 'status' => (string) $post->post_status,
283 'type' => (string) $post->post_type,
284 'author' => (int) $post->post_author,
285 'date' => (string) $post->post_date_gmt,
286 'modified' => (string) $post->post_modified_gmt,
287 'link' => (string) get_permalink( $post ),
288 );
289 }
290
291 /**
292 * `desktop-mode/get-post` permission callback.
293 *
294 * Asks every gate a single read has, in the order Core's REST
295 * controllers ask them:
296 *
297 * - A zero id is refused before any fetch: `get_post( 0 )` returns
298 * the global post, which would judge the request against whatever
299 * another plugin left there.
300 * - `read_post` decides visibility (published / private / draft) and
301 * stays the floor for every row.
302 * - The post password is a separate question — WordPress splits the
303 * two deliberately. A sealed post stays sealed unless the caller can
304 * edit it (the same escape hatch
305 * `WP_REST_Posts_Controller::check_password_required()` grants), and
306 * because this ability returns RAW `post_content` there is no empty
307 * rendered field to fall back to, so the answer is to refuse.
308 * - A post type with no readable front end (`is_post_type_viewable()`
309 * false: an order, a submission log, a queue entry) is read only by
310 * a caller who can edit the row. `map_meta_cap()` resolves
311 * `read_post` on a published row of such a type to plain `read`,
312 * which every logged-in user holds, so `read_post` alone does not
313 * answer the question for it.
314 *
315 * `openstation_ai_can_read_post()` (loaded unconditionally from the AI
316 * Copilot bootstrap, ahead of this module) implements the password and
317 * post-type gates; this callback keeps `read_post` in front of it so a
318 * plugin that narrows `read_post` on a public post still narrows this
319 * ability.
320 *
321 * @param array $args Input args.
322 * @return bool
323 */
324 function openstation_agents_ability_get_post_can( $args ) {
325 $args = (array) $args;
326 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
327 if ( $post_id <= 0 ) {
328 return false;
329 }
330 if ( ! current_user_can( 'read_post', $post_id ) ) {
331 return false;
332 }
333 return openstation_ai_can_read_post( $post_id );
334 }
335
336 /**
337 * `desktop-mode/get-media` execute callback.
338 *
339 * @param array $args Validated input.
340 * @return array|WP_Error
341 */
342 function openstation_agents_ability_get_media( $args ) {
343 $args = (array) $args;
344 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
345 $post = $attachment_id > 0 ? get_post( $attachment_id ) : null;
346 if ( ! ( $post instanceof WP_Post ) || 'attachment' !== $post->post_type ) {
347 return new WP_Error( 'openstation_agent_media_not_found', __( 'Attachment not found.', 'desktop-mode' ) );
348 }
349
350 $meta = wp_get_attachment_metadata( $attachment_id );
351 if ( ! is_array( $meta ) ) {
352 $meta = array();
353 }
354
355 return array(
356 'id' => (int) $post->ID,
357 'title' => (string) $post->post_title,
358 'url' => (string) wp_get_attachment_url( $attachment_id ),
359 'mime' => (string) get_post_mime_type( $post ),
360 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : null,
361 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : null,
362 'filesize' => isset( $meta['filesize'] ) ? (int) $meta['filesize'] : null,
363 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
364 'caption' => (string) $post->post_excerpt,
365 'date' => (string) $post->post_date_gmt,
366 'attachedTo' => (int) $post->post_parent,
367 );
368 }
369
370 /**
371 * `desktop-mode/get-media` permission callback.
372 *
373 * Gates on `upload_files` (author+) — the capability the Media
374 * Library itself requires — rather than on `read_post` of the
375 * attachment.
376 *
377 * An attached file is a child of its parent post, and the result
378 * carries the attachment's title, caption and `attachedTo` (the parent
379 * id), so an attached file also requires that the caller can read the
380 * parent. That follows the shape of Core's rule for `inherit`-status
381 * attachments, `WP_REST_Posts_Controller::check_read_permission()`
382 * (the attachments controller inherits it): an attachment defers to
383 * its parent whenever one exists. The parent is judged by `read_post`,
384 * plus the post-type rule `desktop-mode/get-post` applies (a type with
385 * no readable front end needs `edit_post`), which is stricter than Core
386 * on a non-viewable parent: Core admits any `publish` parent of a
387 * REST-enabled type. Core's other requirement, that the parent's type
388 * be `show_in_rest`, is not copied: it would refuse media attached to a
389 * non-REST type for every caller, administrators included. The
390 * parent's password is not asked: the attachment's own fields are not
391 * the parent's body, and Core's attachment read does not ask it either.
392 *
393 * An unattached file, or one whose parent row no longer exists, is
394 * judged on `upload_files` alone, as Core treats a parentless
395 * `inherit` attachment as published. A zero id is refused before any
396 * fetch, because `get_post( 0 )` returns the global post.
397 *
398 * @param array $args Input args.
399 * @return bool
400 */
401 function openstation_agents_ability_get_media_can( $args ) {
402 $args = (array) $args;
403 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
404 if ( $attachment_id <= 0 ) {
405 return false;
406 }
407 if ( ! current_user_can( 'upload_files' ) ) {
408 return false;
409 }
410
411 $attachment = get_post( $attachment_id );
412 if ( ! ( $attachment instanceof WP_Post ) || 'attachment' !== $attachment->post_type ) {
413 // The execute callback answers "not found" for these.
414 return true;
415 }
416
417 $parent_id = (int) $attachment->post_parent;
418 if ( $parent_id <= 0 || $parent_id === $attachment_id ) {
419 return true;
420 }
421 $parent = get_post( $parent_id );
422 if ( ! ( $parent instanceof WP_Post ) ) {
423 return true;
424 }
425
426 if ( ! current_user_can( 'read_post', $parent->ID ) ) {
427 return false;
428 }
429 $parent_type = get_post_type_object( $parent->post_type );
430 if ( ! $parent_type || ! is_post_type_viewable( $parent_type ) ) {
431 return current_user_can( 'edit_post', $parent->ID );
432 }
433 return true;
434 }
435
436 /**
437 * `desktop-mode/update-media` execute callback.
438 *
439 * @param array $args Validated input.
440 * @return array|WP_Error
441 */
442 function openstation_agents_ability_update_media( $args ) {
443 $args = (array) $args;
444 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
445 $post = $attachment_id > 0 ? get_post( $attachment_id ) : null;
446 if ( ! ( $post instanceof WP_Post ) || 'attachment' !== $post->post_type ) {
447 return new WP_Error( 'openstation_agent_media_not_found', __( 'Attachment not found.', 'desktop-mode' ) );
448 }
449
450 if ( isset( $args['alt_text'] ) ) {
451 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( (string) $args['alt_text'] ) );
452 }
453
454 $update = array( 'ID' => $attachment_id );
455 if ( isset( $args['title'] ) ) {
456 $update['post_title'] = sanitize_text_field( (string) $args['title'] );
457 }
458 if ( isset( $args['caption'] ) ) {
459 $update['post_excerpt'] = sanitize_text_field( (string) $args['caption'] );
460 }
461 if ( isset( $args['description'] ) ) {
462 $update['post_content'] = wp_kses_post( (string) $args['description'] );
463 }
464 if ( count( $update ) > 1 ) {
465 $result = wp_update_post( $update, true );
466 if ( is_wp_error( $result ) ) {
467 return $result;
468 }
469 }
470
471 return array(
472 'id' => $attachment_id,
473 'updated' => true,
474 );
475 }
476
477 /**
478 * `desktop-mode/update-media` permission callback — the same edit
479 * capability wp-admin requires to change attachment details.
480 *
481 * @param array $args Input args.
482 * @return bool
483 */
484 function openstation_agents_ability_update_media_can( $args ) {
485 $args = (array) $args;
486 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
487 if ( $attachment_id <= 0 ) {
488 return false;
489 }
490 return current_user_can( 'edit_post', $attachment_id );
491 }
492
493 /**
494 * `desktop-mode/create-post` execute callback. Status is hard-forced
495 * to `draft` — this ability can never publish, whatever the model
496 * asks for.
497 *
498 * @param array $args Validated input.
499 * @return array|WP_Error
500 */
501 function openstation_agents_ability_create_post( $args ) {
502 $args = (array) $args;
503 $type = isset( $args['type'] ) && 'page' === $args['type'] ? 'page' : 'post';
504
505 $post_id = wp_insert_post(
506 array(
507 'post_type' => $type,
508 'post_status' => 'draft',
509 'post_title' => sanitize_text_field( isset( $args['title'] ) ? (string) $args['title'] : '' ),
510 'post_content' => wp_kses_post( isset( $args['content'] ) ? (string) $args['content'] : '' ),
511 'post_excerpt' => sanitize_text_field( isset( $args['excerpt'] ) ? (string) $args['excerpt'] : '' ),
512 'post_author' => get_current_user_id(),
513 ),
514 true
515 );
516 if ( is_wp_error( $post_id ) ) {
517 return $post_id;
518 }
519
520 return array(
521 'id' => (int) $post_id,
522 'type' => $type,
523 'status' => 'draft',
524 'title' => (string) get_the_title( $post_id ),
525 'editLink' => (string) get_edit_post_link( $post_id, 'raw' ),
526 );
527 }
528
529 /**
530 * `desktop-mode/create-post` permission callback.
531 *
532 * @param array $args Input args.
533 * @return bool
534 */
535 function openstation_agents_ability_create_post_can( $args ) {
536 $args = (array) $args;
537 if ( isset( $args['type'] ) && 'page' === $args['type'] ) {
538 return current_user_can( 'edit_pages' );
539 }
540 return current_user_can( 'edit_posts' );
541 }
542
543 /**
544 * `desktop-mode/update-post` execute callback.
545 *
546 * @param array $args Validated input.
547 * @return array|WP_Error
548 */
549 function openstation_agents_ability_update_post( $args ) {
550 $args = (array) $args;
551 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
552 if ( $post_id <= 0 || ! get_post( $post_id ) ) {
553 return new WP_Error( 'openstation_agent_post_not_found', __( 'Post not found.', 'desktop-mode' ) );
554 }
555
556 $update = array( 'ID' => $post_id );
557 if ( isset( $args['title'] ) ) {
558 $update['post_title'] = sanitize_text_field( (string) $args['title'] );
559 }
560 if ( isset( $args['content'] ) ) {
561 $update['post_content'] = wp_kses_post( (string) $args['content'] );
562 }
563 if ( isset( $args['excerpt'] ) ) {
564 $update['post_excerpt'] = sanitize_text_field( (string) $args['excerpt'] );
565 }
566 if ( isset( $args['status'] ) ) {
567 $status = sanitize_key( (string) $args['status'] );
568 if ( ! in_array( $status, array( 'publish', 'draft', 'pending', 'private' ), true ) ) {
569 return new WP_Error( 'openstation_agent_invalid_status', __( 'Invalid post status.', 'desktop-mode' ) );
570 }
571 $update['post_status'] = $status;
572 }
573
574 $result = wp_update_post( $update, true );
575 if ( is_wp_error( $result ) ) {
576 return $result;
577 }
578 return array(
579 'id' => (int) $result,
580 'updated' => true,
581 );
582 }
583
584 /**
585 * `desktop-mode/update-post` permission callback.
586 *
587 * Publishing needs `publish_posts` on top of `edit_post` — the same
588 * split wp-admin enforces on a human editor.
589 *
590 * @param array $args Input args.
591 * @return bool
592 */
593 function openstation_agents_ability_update_post_can( $args ) {
594 $args = (array) $args;
595 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
596 if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
597 return false;
598 }
599 if ( isset( $args['status'] ) && 'publish' === $args['status'] && ! current_user_can( 'publish_posts' ) ) {
600 return false;
601 }
602 return true;
603 }
604
605 /**
606 * Catalogue of abilities exposed to the agents picker.
607 *
608 * Primary source: Core's Abilities API (`wp_get_abilities()`) — every
609 * ability the site registered, Core's, this plugin's, or any third
610 * party's, projected into the picker shape with an honest
611 * readonly/mutating badge derived from `meta.annotations.readonly`.
612 *
613 * @return array<int, array{slug:string, label:string, description:string, category:string, readonly:bool}>
614 */
615 function openstation_agents_abilities_catalogue() {
616 $catalogue = array();
617
618 if ( function_exists( 'wp_get_abilities' ) ) {
619 foreach ( wp_get_abilities() as $ability ) {
620 if ( ! $ability instanceof WP_Ability ) {
621 continue;
622 }
623 $meta = (array) $ability->get_meta();
624 $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : array();
625
626 $catalogue[] = array(
627 'slug' => (string) $ability->get_name(),
628 'label' => (string) $ability->get_label(),
629 'description' => (string) $ability->get_description(),
630 'category' => (string) $ability->get_category(),
631 'readonly' => ! empty( $annotations['readonly'] ),
632 );
633 }
634 }
635
636 /**
637 * Filter the catalogue of abilities exposed to the agents picker.
638 *
639 * Sites can narrow the pickable set (drop rows) or append
640 * Desktop-Mode-only entries. The preferred extension path stays
641 * `wp_register_ability()` so every agent runtime sees the same
642 * registry.
643 *
644 * @param array $catalogue Abilities projected from `wp_get_abilities()`.
645 */
646 $catalogue = apply_filters( 'openstation_agent_abilities_catalogue', $catalogue );
647 if ( ! is_array( $catalogue ) ) {
648 return array();
649 }
650
651 $seen = array();
652 $out = array();
653 foreach ( $catalogue as $row ) {
654 if ( ! is_array( $row ) || empty( $row['slug'] ) ) {
655 continue;
656 }
657 $slug = sanitize_text_field( (string) $row['slug'] );
658 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
659 continue;
660 }
661 $seen[ $slug ] = true;
662 $out[] = array(
663 'slug' => $slug,
664 'label' => isset( $row['label'] ) && '' !== (string) $row['label'] ? (string) $row['label'] : $slug,
665 'description' => isset( $row['description'] ) ? (string) $row['description'] : '',
666 'category' => isset( $row['category'] ) ? (string) $row['category'] : '',
667 'readonly' => ! empty( $row['readonly'] ),
668 );
669 }
670 return $out;
671 }
672