| @@ -104,8 +104,53 @@ | ||
| 104 | 104 | return trim( (string) $slug, '_' ); |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | 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 | +/** | |
| 108 | 153 | * Permission callback: any logged-in user who can read the site. |
| 109 | 154 | * |
| 110 | 155 | * Mirrors the read-only search/navigation tools, which were ungated beyond the |
| 111 | 156 | * Copilot's own logged-in requirement. |
| @@ -152,9 +197,9 @@ | ||
| 152 | 197 | 'required' => array( 'query', 'offset' ), |
| 153 | 198 | 'properties' => array( |
| 154 | 199 | 'query' => array( |
| 155 | 200 | '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.', | |
| 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.', | |
| 157 | 202 | ), |
| 158 | 203 | 'offset' => array( |
| 159 | 204 | 'type' => 'integer', |
| 160 | 205 | 'description' => 'Zero-based starting position. Use 0 for the first batch, 10 for the second, and so on.', |
| @@ -208,9 +253,9 @@ | ||
| 208 | 253 | wp_register_ability( |
| 209 | 254 | 'desktop-mode/search-posts', |
| 210 | 255 | array( |
| 211 | 256 | '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.', | |
| 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.', | |
| 213 | 258 | 'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 214 | 259 | 'input_schema' => $query_offset_input, |
| 215 | 260 | 'output_schema' => $search_output, |
| 216 | 261 | 'execute_callback' => static function ( $input ) { |
| @@ -240,9 +285,9 @@ | ||
| 240 | 285 | wp_register_ability( |
| 241 | 286 | 'desktop-mode/search-comments', |
| 242 | 287 | array( |
| 243 | 288 | 'label' => __( 'Search comments', 'desktop-mode' ), |
| 244 | - '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.', | |
| 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.', | |
| 245 | 290 | 'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 246 | 291 | 'input_schema' => $query_offset_input, |
| 247 | 292 | 'output_schema' => $search_output, |
| 248 | 293 | 'execute_callback' => static function ( $input ) { |
| @@ -256,9 +301,9 @@ | ||
| 256 | 301 | wp_register_ability( |
| 257 | 302 | 'desktop-mode/search-comments-by-post', |
| 258 | 303 | array( |
| 259 | 304 | 'label' => __( 'Search comments on a post', 'desktop-mode' ), |
| 260 | - '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.', | |
| 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.', | |
| 261 | 306 | 'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 262 | 307 | 'input_schema' => array( |
| 263 | 308 | 'type' => 'object', |
| 264 | 309 | 'additionalProperties' => false, |
| @@ -324,9 +369,9 @@ | ||
| 324 | 369 | wp_register_ability( |
| 325 | 370 | 'desktop-mode/search-wporg-plugins', |
| 326 | 371 | array( |
| 327 | 372 | '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.', | |
| 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.', | |
| 329 | 374 | 'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 330 | 375 | 'input_schema' => array( |
| 331 | 376 | 'type' => 'object', |
| 332 | 377 | 'additionalProperties' => false, |
| @@ -341,9 +386,9 @@ | ||
| 341 | 386 | 'output_schema' => openstation_ai_ability_output_schema( |
| 342 | 387 | array( |
| 343 | 388 | 'results' => array( |
| 344 | 389 | 'type' => 'array', |
| 345 | - 'description' => 'Matching plugins with name, description, rating, installs, and admin URL.', | |
| 390 | + 'description' => 'Matching plugins with name, description, rating (0-100), stars (0-5), installs, and admin URL.', | |
| 346 | 391 | ), |
| 347 | 392 | 'count' => array( 'type' => 'integer' ), |
| 348 | 393 | ) |
| 349 | 394 | ), |
| @@ -399,11 +444,14 @@ | ||
| 399 | 444 | /** |
| 400 | 445 | * Registers the comment-spam analysis ability. |
| 401 | 446 | * |
| 402 | 447 | * 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. | |
| 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. | |
| 406 | 454 | * |
| 407 | 455 | * @return void |
| 408 | 456 | */ |
| 409 | 457 | function openstation_ai_register_comment_analysis_ability() { |
| @@ -410,9 +458,9 @@ | ||
| 410 | 458 | wp_register_ability( |
| 411 | 459 | 'desktop-mode/analyze-comment', |
| 412 | 460 | array( |
| 413 | 461 | '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.', | |
| 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.', | |
| 415 | 463 | 'category' => OPENSTATION_AI_ABILITY_CATEGORY, |
| 416 | 464 | 'input_schema' => array( |
| 417 | 465 | 'type' => 'object', |
| 418 | 466 | 'additionalProperties' => false, |