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 / search.php

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

2,635 lines 100.4 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 content search via the provider tool use.
4 *
5 * Agentic search loop: the user describes something in natural language and
6 * the agent calls focused tools, choosing the right one based on query
7 * semantics. Built-in tools: four content-search tools — search_posts,
8 * search_pages, search_comments, search_comments_by_post — plus
9 * list_admin_pages (admin navigation catalog), search_wporg_plugins
10 * (WordPress.org plugin directory), and get_php_error_log (error-log
11 * tail). Each content-search tool runs WordPress's native search
12 * (WP_Query `s=` / get_comments `search=`) for the keywords the model
13 * distils from the request, then returns up to 10 matching entities with
14 * their real title + content excerpt for the model to compare to the
15 * user's description. No AI pre-analysis is required — every published
16 * post/page/comment is findable. The built-in tools are WordPress Abilities
17 * (see abilities.php); client command tools are advertised alongside them and
18 * dispatched by the same loop.
19 *
20 * Focused tools instead of one routing parameter:
21 * - "I remember a comment where someone said congratulations…" → agent
22 * calls search_comments without needing a routing parameter.
23 * - "I wrote a post about paella in Canarias" → agent calls search_posts.
24 * - "Our About page mentions…" → agent calls search_pages.
25 * - Ambiguous queries → agent tries in priority order (posts → pages →
26 * comments) following the system-prompt guidance.
27 *
28 * Budget: max OPENSTATION_AI_SEARCH_MAX_ITERATIONS (10) tool-call rounds per
29 * request × OPENSTATION_AI_SEARCH_BATCH_SIZE (10) items = up to 100 entities.
30 * When the budget is exhausted the response includes a `continue` object
31 * the client uses to resume from the exact offset that was last searched.
32 *
33 * REST endpoint: POST /desktop-mode/v1/ai/search
34 *
35 * @package OpenStation
36 */
37
38 defined( 'ABSPATH' ) || exit;
39
40 /** Maximum agentic tool-call iterations per search request. */
41 const OPENSTATION_AI_SEARCH_MAX_ITERATIONS = 10;
42
43 /** Entities fetched per tool-call round. */
44 const OPENSTATION_AI_SEARCH_BATCH_SIZE = 10;
45
46 /**
47 * Returns the catalog of common WordPress admin destinations.
48 *
49 * Used by the `list_admin_pages` tool. Each entry has a human title, the
50 * wp-admin URL (rendered through admin_url() so it respects the site's
51 * real admin path), a short description, and a Dashicons icon class the
52 * UI can use when opening the URL in a legacy iframe window.
53 *
54 * Filterable via `openstation_ai_admin_page_catalog` so third-party
55 * plugins can contribute their own admin destinations (e.g. a plugin
56 * adding a top-level menu can surface its settings page here).
57 *
58 * @return array[]
59 */
60 function openstation_ai_get_admin_page_catalog() {
61 $catalog = array(
62 array(
63 'title' => __( 'Dashboard', 'desktop-mode' ),
64 'url' => admin_url( 'index.php' ),
65 'icon' => 'dashicons-dashboard',
66 'description' => __( 'The main admin dashboard: activity, drafts, site overview.', 'desktop-mode' ),
67 ),
68 array(
69 'title' => __( 'All Posts', 'desktop-mode' ),
70 'url' => admin_url( 'edit.php' ),
71 'icon' => 'dashicons-admin-post',
72 'description' => __( 'List, edit, bulk-manage blog posts.', 'desktop-mode' ),
73 ),
74 array(
75 'title' => __( 'Add New Post', 'desktop-mode' ),
76 'url' => admin_url( 'post-new.php' ),
77 'icon' => 'dashicons-plus',
78 'description' => __( 'Create a new blog post.', 'desktop-mode' ),
79 ),
80 array(
81 'title' => __( 'Categories', 'desktop-mode' ),
82 'url' => admin_url( 'edit-tags.php?taxonomy=category' ),
83 'icon' => 'dashicons-category',
84 'description' => __( 'Manage post categories: add, rename, merge.', 'desktop-mode' ),
85 ),
86 array(
87 'title' => __( 'Tags', 'desktop-mode' ),
88 'url' => admin_url( 'edit-tags.php?taxonomy=post_tag' ),
89 'icon' => 'dashicons-tag',
90 'description' => __( 'Manage post tags.', 'desktop-mode' ),
91 ),
92 array(
93 'title' => __( 'All Pages', 'desktop-mode' ),
94 'url' => admin_url( 'edit.php?post_type=page' ),
95 'icon' => 'dashicons-admin-page',
96 'description' => __( 'List and edit static pages (About, Contact, etc.).', 'desktop-mode' ),
97 ),
98 array(
99 'title' => __( 'Add New Page', 'desktop-mode' ),
100 'url' => admin_url( 'post-new.php?post_type=page' ),
101 'icon' => 'dashicons-plus',
102 'description' => __( 'Create a new page.', 'desktop-mode' ),
103 ),
104 array(
105 'title' => __( 'Media Library', 'desktop-mode' ),
106 'url' => admin_url( 'upload.php' ),
107 'icon' => 'dashicons-admin-media',
108 'description' => __( 'Browse, upload, and manage images, files, videos.', 'desktop-mode' ),
109 ),
110 array(
111 'title' => __( 'Comments', 'desktop-mode' ),
112 'url' => admin_url( 'edit-comments.php' ),
113 'icon' => 'dashicons-admin-comments',
114 'description' => __( 'Moderate and reply to comments on posts and pages.', 'desktop-mode' ),
115 ),
116 array(
117 'title' => __( 'Themes', 'desktop-mode' ),
118 'url' => admin_url( 'themes.php' ),
119 'icon' => 'dashicons-admin-appearance',
120 'description' => __( 'Change, install, or customize the active theme.', 'desktop-mode' ),
121 ),
122 array(
123 'title' => __( 'Customize', 'desktop-mode' ),
124 'url' => admin_url( 'customize.php' ),
125 'icon' => 'dashicons-admin-customizer',
126 'description' => __( 'Live-preview theme customisation: colors, fonts, layout.', 'desktop-mode' ),
127 ),
128 array(
129 'title' => __( 'Widgets', 'desktop-mode' ),
130 'url' => admin_url( 'widgets.php' ),
131 'icon' => 'dashicons-screenoptions',
132 'description' => __( 'Manage sidebar and footer widgets.', 'desktop-mode' ),
133 ),
134 array(
135 'title' => __( 'Menus', 'desktop-mode' ),
136 'url' => admin_url( 'nav-menus.php' ),
137 'icon' => 'dashicons-menu',
138 'description' => __( 'Create and edit navigation menus.', 'desktop-mode' ),
139 ),
140 array(
141 'title' => __( 'Plugins', 'desktop-mode' ),
142 'url' => admin_url( 'plugins.php' ),
143 'icon' => 'dashicons-admin-plugins',
144 'description' => __( 'Activate, deactivate, update or delete plugins.', 'desktop-mode' ),
145 ),
146 array(
147 'title' => __( 'Add New Plugin', 'desktop-mode' ),
148 'url' => admin_url( 'plugin-install.php' ),
149 'icon' => 'dashicons-plus',
150 'description' => __( 'Search and install new plugins from the directory.', 'desktop-mode' ),
151 ),
152 array(
153 'title' => __( 'Users', 'desktop-mode' ),
154 'url' => admin_url( 'users.php' ),
155 'icon' => 'dashicons-admin-users',
156 'description' => __( 'Manage user accounts and roles.', 'desktop-mode' ),
157 ),
158 array(
159 'title' => __( 'Add New User', 'desktop-mode' ),
160 'url' => admin_url( 'user-new.php' ),
161 'icon' => 'dashicons-plus',
162 'description' => __( 'Create a new user account.', 'desktop-mode' ),
163 ),
164 array(
165 'title' => __( 'Your Profile', 'desktop-mode' ),
166 'url' => admin_url( 'profile.php' ),
167 'icon' => 'dashicons-id',
168 'description' => __( 'Edit your own profile, password, admin colour scheme.', 'desktop-mode' ),
169 ),
170 array(
171 'title' => __( 'General Settings', 'desktop-mode' ),
172 'url' => admin_url( 'options-general.php' ),
173 'icon' => 'dashicons-admin-settings',
174 'description' => __( 'Site title, tagline, URL, timezone, language.', 'desktop-mode' ),
175 ),
176 array(
177 'title' => __( 'Writing Settings', 'desktop-mode' ),
178 'url' => admin_url( 'options-writing.php' ),
179 'icon' => 'dashicons-edit',
180 'description' => __( 'Default post category, post format, remote publishing.', 'desktop-mode' ),
181 ),
182 array(
183 'title' => __( 'Reading Settings', 'desktop-mode' ),
184 'url' => admin_url( 'options-reading.php' ),
185 'icon' => 'dashicons-book',
186 'description' => __( 'Homepage, blog posts per page, search-engine visibility.', 'desktop-mode' ),
187 ),
188 array(
189 'title' => __( 'Discussion Settings', 'desktop-mode' ),
190 'url' => admin_url( 'options-discussion.php' ),
191 'icon' => 'dashicons-format-chat',
192 'description' => __( 'Comment moderation, avatars, email notifications.', 'desktop-mode' ),
193 ),
194 array(
195 'title' => __( 'Media Settings', 'desktop-mode' ),
196 'url' => admin_url( 'options-media.php' ),
197 'icon' => 'dashicons-format-image',
198 'description' => __( 'Image size settings for thumbnail / medium / large.', 'desktop-mode' ),
199 ),
200 array(
201 'title' => __( 'Permalinks', 'desktop-mode' ),
202 'url' => admin_url( 'options-permalink.php' ),
203 'icon' => 'dashicons-admin-links',
204 'description' => __( 'URL structure for posts, pages, categories, tags.', 'desktop-mode' ),
205 ),
206 array(
207 'title' => __( 'Privacy', 'desktop-mode' ),
208 'url' => admin_url( 'options-privacy.php' ),
209 'icon' => 'dashicons-privacy',
210 'description' => __( 'Privacy policy page selection and preview.', 'desktop-mode' ),
211 ),
212 array(
213 'title' => __( 'Tools', 'desktop-mode' ),
214 'url' => admin_url( 'tools.php' ),
215 'icon' => 'dashicons-admin-tools',
216 'description' => __( 'Built-in site tools.', 'desktop-mode' ),
217 ),
218 array(
219 'title' => __( 'Import', 'desktop-mode' ),
220 'url' => admin_url( 'import.php' ),
221 'icon' => 'dashicons-download',
222 'description' => __( 'Import content from other platforms (WP, Tumblr, RSS, etc.).', 'desktop-mode' ),
223 ),
224 array(
225 'title' => __( 'Export', 'desktop-mode' ),
226 'url' => admin_url( 'export.php' ),
227 'icon' => 'dashicons-upload',
228 'description' => __( 'Export all site content as XML.', 'desktop-mode' ),
229 ),
230 array(
231 'title' => __( 'Site Health', 'desktop-mode' ),
232 'url' => admin_url( 'site-health.php' ),
233 'icon' => 'dashicons-heart',
234 'description' => __( 'Performance and security recommendations for the site.', 'desktop-mode' ),
235 ),
236 array(
237 'title' => __( 'Updates', 'desktop-mode' ),
238 'url' => admin_url( 'update-core.php' ),
239 'icon' => 'dashicons-update',
240 'description' => __( 'WordPress, theme, and plugin updates.', 'desktop-mode' ),
241 ),
242 );
243
244 /**
245 * Filters the wp-admin page catalog surfaced by the AI assistant.
246 *
247 * @param array[] $catalog Array of entries, each with title/url/icon/description.
248 */
249 return (array) apply_filters( 'openstation_ai_admin_page_catalog', $catalog );
250 }
251
252 // ---------------------------------------------------------------------------
253 // Final-answer JSON Schema
254 // ---------------------------------------------------------------------------
255
256 /**
257 * JSON Schema for the agent's final structured answer.
258 *
259 * @return array
260 */
261 function openstation_ai_search_answer_schema() {
262 return array(
263 'type' => 'object',
264 'additionalProperties' => false,
265 'required' => array( 'answer_type', 'message', 'entity_id', 'entity_type', 'admin_links' ),
266 'properties' => array(
267 'answer_type' => array(
268 'type' => 'string',
269 'enum' => array( 'entity', 'navigation', 'chat' ),
270 'description' => 'Classification of the answer: "entity" when you identified a specific post/page/comment the user was asking about. "navigation" when you are returning admin_links: wp-admin destinations, plugin install links, or several matching posts/pages/comments. "chat" for everything else, including summaries of tool results (error logs, site info), greetings, clarifications and "I couldn\'t find anything".',
271 ),
272 'message' => array(
273 'type' => 'string',
274 'description' => 'A friendly, conversational response to show the user. Write in first person like a helpful assistant (e.g. "I found your Málaga post — this one", "Here\'s where you manage categories"). NOT a search-engine sentence ("Match found").',
275 ),
276 'entity_id' => array(
277 'anyOf' => array(
278 array( 'type' => 'integer' ),
279 array( 'type' => 'null' ),
280 ),
281 'description' => 'The WordPress ID of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
282 ),
283 'entity_type' => array(
284 'anyOf' => array(
285 array(
286 'type' => 'string',
287 'enum' => array( 'post', 'page', 'comment' ),
288 ),
289 array( 'type' => 'null' ),
290 ),
291 'description' => 'Type of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
292 ),
293 'admin_links' => array(
294 'anyOf' => array(
295 array(
296 'type' => 'array',
297 'items' => array(
298 'type' => 'object',
299 'additionalProperties' => false,
300 'required' => array( 'title', 'url', 'description', 'icon' ),
301 'properties' => array(
302 'title' => array( 'type' => 'string' ),
303 'url' => array( 'type' => 'string' ),
304 'description' => array( 'type' => 'string' ),
305 'icon' => array( 'type' => 'string' ),
306 ),
307 ),
308 ),
309 array( 'type' => 'null' ),
310 ),
311 'description' => 'List of 1-3 destinations: wp-admin pages (copy verbatim from the list_admin_pages tool result), plugin install links, or several matching posts/pages/comments (url = the item\'s edit_url from the search result). Required when answer_type is "navigation"; set to null otherwise.',
312 ),
313 ),
314 );
315 }
316
317 // ---------------------------------------------------------------------------
318 // DB queries — tool execution
319 // ---------------------------------------------------------------------------
320
321 /**
322 * Routes a tool call to the correct DB query by function name.
323 *
324 * The content-search tools (`search_posts`, `search_pages`,
325 * `search_comments`, `search_comments_by_post`) take a keyword `query`
326 * matched with WordPress's native search; `search_comments_by_post`
327 * needs an additional `post_id`. The caller passes the full decoded
328 * arguments array so this function can extract whatever it needs.
329 *
330 * @param string $tool_name Tool function name.
331 * @param array $args Decoded arguments from the model's tool call.
332 * @return array Tool result payload.
333 */
334 function openstation_ai_search_dispatch_tool( $tool_name, array $args ) {
335 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
336 $query = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : '';
337
338 switch ( $tool_name ) {
339 case 'search_posts':
340 return openstation_ai_search_fetch_posts( 'post', $query, $offset );
341 case 'search_pages':
342 return openstation_ai_search_fetch_posts( 'page', $query, $offset );
343 case 'search_comments':
344 return openstation_ai_search_fetch_comments( $query, $offset );
345 case 'search_comments_by_post':
346 $post_id = max( 0, (int) ( $args['post_id'] ?? 0 ) );
347 return openstation_ai_search_fetch_comments_by_post( $post_id, $query, $offset );
348 case 'list_admin_pages':
349 return array(
350 'tool' => 'list_admin_pages',
351 'pages' => openstation_ai_get_admin_page_catalog(),
352 );
353 case 'search_wporg_plugins':
354 $q = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : '';
355 return openstation_ai_fetch_wporg_plugins( $q );
356 case 'get_php_error_log':
357 if ( ! current_user_can( 'manage_options' ) ) {
358 return array(
359 'tool' => 'get_php_error_log',
360 'log_available' => false,
361 'error' => 'Only administrators can access the PHP error log.',
362 'entries' => array(),
363 );
364 }
365 $lines = isset( $args['lines'] ) ? max( 1, min( 500, (int) $args['lines'] ) ) : 50;
366 return openstation_ai_fetch_error_log( $lines );
367 }
368
369 return array(
370 'tool' => $tool_name,
371 'offset' => $offset,
372 'items' => array(),
373 'count' => 0,
374 'total' => 0,
375 'has_more' => false,
376 'error' => "Unknown tool '{$tool_name}'.",
377 );
378 }
379
380 /**
381 * Keyword-searches published posts or pages with WordPress's native search
382 * (`WP_Query` `s=`), returning data rich enough for the agent to compare
383 * AND for the UI to render links.
384 *
385 * No AI analysis is required — every published, non-password-protected post/page is searchable.
386 *
387 * Password-protected posts are excluded (`has_password => false`): `publish`
388 * is also the status of a password-protected post, and this tool emits the
389 * stored body as an excerpt without ever passing through `post_password_required()`.
390 * Filtering at the query level keeps them out of both `items` and `found_posts`,
391 * so the `total` counter cannot become an oracle for their contents either.
392 *
393 * @param string $post_type 'post' | 'page'.
394 * @param string $query Keyword search terms (may be empty to list newest).
395 * @param int $offset
396 * @return array
397 */
398 function openstation_ai_search_fetch_posts( $post_type, $query, $offset ) {
399 $wp_query = new WP_Query(
400 array(
401 'post_type' => $post_type,
402 'post_status' => 'publish',
403 'has_password' => false,
404 's' => (string) $query,
405 'posts_per_page' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
406 'offset' => $offset,
407 'no_found_rows' => false,
408 'update_post_term_cache' => false,
409 'update_post_meta_cache' => false,
410 )
411 );
412
413 $items = array();
414 foreach ( $wp_query->posts as $post ) {
415 $items[] = array(
416 // Identity — used to build the final entity detail.
417 'id' => $post->ID,
418 'type' => $post->post_type,
419 // Comparison data for the model — real title + content excerpt.
420 'title' => wp_strip_all_tags( $post->post_title ),
421 'excerpt' => openstation_ai_search_excerpt( $post->post_content, $query ),
422 'date' => $post->post_date ? substr( $post->post_date, 0, 10 ) : '',
423 // Links — passed through so the UI can link to the entity
424 // once the agent identifies a match.
425 'url' => (string) get_permalink( $post ),
426 'edit_url' => (string) get_edit_post_link( $post->ID, 'raw' ),
427 );
428 }
429
430 $total = (int) $wp_query->found_posts;
431
432 return array(
433 'tool' => 'search_' . $post_type . 's',
434 'query' => (string) $query,
435 'offset' => $offset,
436 'items' => $items,
437 'count' => count( $items ),
438 'total' => $total,
439 'has_more' => ( $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE ) < $total,
440 'next_offset' => $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE,
441 );
442 }
443
444 /**
445 * Trims raw post/comment content into a plain-text excerpt for the model.
446 *
447 * With a keyword query the window is centred on the first matching word,
448 * so the model can see WHY an item matched. Always cutting from the start
449 * hid the match whenever it sat past the first 300 characters, and the
450 * model then discarded genuine hits as irrelevant and kept searching.
451 *
452 * @param string $content Raw post/comment content.
453 * @param string $query Optional. The keyword query the item matched.
454 * @return string
455 */
456 function openstation_ai_search_excerpt( $content, $query = '' ) {
457 $length = 300;
458 $text = wp_strip_all_tags( (string) $content );
459 $text = preg_replace( '/\s+/', ' ', trim( $text ) );
460
461 $start = 0;
462 foreach ( preg_split( '/\s+/', trim( (string) $query ) ) as $word ) {
463 $pos = '' !== $word ? mb_stripos( $text, $word ) : false;
464 if ( false !== $pos ) {
465 // A third of the window as lead-in, so the match reads in context.
466 $start = max( 0, $pos - (int) ( $length / 3 ) );
467 break;
468 }
469 }
470
471 $excerpt = (string) mb_substr( $text, $start, $length );
472 return $start > 0 ? '…' . $excerpt : $excerpt;
473 }
474
475 /**
476 * Whether the current user may read a post the comment tools are about to
477 * surface.
478 *
479 * A comment being `approved` is a moderation decision — it says nothing about
480 * who may see the discussion. An approved comment can hang on a private,
481 * draft, or password-protected post the caller cannot reach, so the comment
482 * search tools must gate on the PARENT POST's visibility before returning the
483 * comment text or the parent title. Mirrors Core's
484 * `WP_REST_Comments_Controller::check_read_post_permission()`:
485 *
486 * - a password-protected parent needs the password satisfied or `edit_post`.
487 * `post_password_required()` honours the `wp-postpass` cookie Core's
488 * password form sets, and that is deliberate Core parity, not a gap: the
489 * cookie only exists because the caller already entered the correct
490 * password, and Core's comments controller reads the same cookie. The
491 * ability itself has no password input, so a caller who never unlocked
492 * the post front-end is refused;
493 * - a publicly viewable parent (public status AND viewable post type) is
494 * readable by anyone the ability admits;
495 * - a parent whose post TYPE is not viewable (an internal/admin-only CPT)
496 * needs `edit_post` — `read_post` cannot stand in, because a public status
497 * resolves it to plain `read` whatever the type's visibility, which is how
498 * Core's REST layer needs its own post-type gate too;
499 * - any other parent (private, draft, pending, …) needs `read_post`.
500 *
501 * @param int|WP_Post $post Post ID or object.
502 * @return bool
503 */
504 function openstation_ai_can_read_post( $post ) {
505 // An id of 0 must stay unreadable: get_post( 0 ) falls back to the global
506 // $post, which would judge an orphaned comment against an unrelated post.
507 if ( is_numeric( $post ) && (int) $post <= 0 ) {
508 return false;
509 }
510
511 $post = get_post( $post );
512 if ( ! $post instanceof WP_Post ) {
513 return false;
514 }
515
516 if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
517 return false;
518 }
519
520 if ( is_post_publicly_viewable( $post ) ) {
521 return true;
522 }
523
524 $post_type = get_post_type_object( $post->post_type );
525 if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
526 return current_user_can( 'edit_post', $post->ID );
527 }
528
529 return current_user_can( 'read_post', $post->ID );
530 }
531
532 /**
533 * Whether the current user may read the post a comment is attached to.
534 *
535 * Used to drop comments on posts the caller cannot see from the comment
536 * search results. See {@see openstation_ai_can_read_post()}.
537 *
538 * @param int|WP_Comment $comment Comment ID or object.
539 * @return bool
540 */
541 function openstation_ai_can_read_comment_parent( $comment ) {
542 $comment = get_comment( $comment );
543 if ( ! $comment instanceof WP_Comment ) {
544 return false;
545 }
546 return openstation_ai_can_read_post( (int) $comment->comment_post_ID );
547 }
548
549 /**
550 * Keyword-searches approved comments across all posts with WordPress's
551 * native comment search (`get_comments` `search=`).
552 *
553 * No AI analysis is required — every approved comment is searchable.
554 *
555 * @param string $query Keyword search terms (may be empty to list newest).
556 * @param int $offset
557 * @return array
558 */
559 function openstation_ai_search_fetch_comments( $query, $offset ) {
560 $base_args = array(
561 'status' => 'approve',
562 'type' => 'comment',
563 'search' => (string) $query,
564 );
565
566 $comments = get_comments(
567 array_merge(
568 $base_args,
569 array(
570 'number' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
571 'offset' => $offset,
572 'count' => false,
573 )
574 )
575 );
576
577 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
578
579 // Prime the parent posts in a single query so the per-comment
580 // get_post() calls below are cache hits, not N+1 round-trips.
581 $parent_ids = array_unique(
582 array_map(
583 static function ( $c ) {
584 return (int) $c->comment_post_ID;
585 },
586 $comments
587 )
588 );
589 if ( $parent_ids ) {
590 _prime_post_caches( $parent_ids, false, false );
591 }
592
593 // "Approved" is a moderation decision, not a visibility one: drop comments
594 // whose parent post the caller cannot read (private / draft / password /
595 // internal CPT), so the comment text and the parent title never leak. See
596 // openstation_ai_can_read_comment_parent().
597 //
598 // This runs per row, after the batch, and that is the price of gating on
599 // per-caller readability: an Administrator reads comments on private
600 // posts and a reader who entered a post password reads that post's
601 // discussion, neither of which a single `post_status` or `has_password`
602 // query var can express. `total` therefore counts rows this caller does
603 // not get, and a batch can come back short. The alternative — a blanket
604 // publish-only, no-password query — would be exact and would also hide
605 // those discussions from the people entitled to them.
606 $comments = array_values( array_filter( $comments, 'openstation_ai_can_read_comment_parent' ) );
607
608 $items = array();
609 foreach ( $comments as $comment ) {
610 // Readable, per the filter above.
611 $parent_post = get_post( $comment->comment_post_ID );
612 $parent_title = wp_strip_all_tags( $parent_post->post_title );
613
614 $items[] = array(
615 'id' => (int) $comment->comment_ID,
616 'type' => 'comment',
617 // Comparison data — real comment text + parent post title.
618 'post_title' => $parent_title,
619 // The name the post shows beside the comment, never its email or IP.
620 'author_name' => openstation_plain_text_title( get_comment_author( $comment ) ),
621 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content, $query ),
622 // Links.
623 'url' => (string) get_comment_link( $comment ),
624 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
625 'post_id' => (int) $comment->comment_post_ID,
626 'post_url' => (string) get_permalink( $parent_post ),
627 );
628 }
629
630 return array(
631 'tool' => 'search_comments',
632 'query' => (string) $query,
633 'offset' => $offset,
634 'items' => $items,
635 'count' => count( $items ),
636 'total' => $total,
637 'has_more' => ( $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE ) < $total,
638 'next_offset' => $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE,
639 );
640 }
641
642 // ---------------------------------------------------------------------------
643 // Entity detail builder — final REST response
644 // ---------------------------------------------------------------------------
645
646 /**
647 * Keyword-searches approved comments on a specific post.
648 *
649 * Used by the `search_comments_by_post` tool — the model calls this after
650 * identifying a post via `search_posts`, giving it a scoped, precise set of
651 * comments to compare against the user's description. An empty `$query`
652 * lists the post's comments without keyword filtering.
653 *
654 * @param int $post_id The WordPress post ID.
655 * @param string $query Keyword search terms (may be empty).
656 * @param int $offset
657 * @return array Tool result payload.
658 */
659 function openstation_ai_search_fetch_comments_by_post( $post_id, $query, $offset ) {
660 $post_id = (int) $post_id;
661
662 if ( $post_id <= 0 ) {
663 return array(
664 'tool' => 'search_comments_by_post',
665 'post_id' => $post_id,
666 'offset' => $offset,
667 'items' => array(),
668 'count' => 0,
669 'total' => 0,
670 'has_more' => false,
671 'error' => 'post_id must be a positive integer.',
672 );
673 }
674
675 // The model picks the post id, so it is untrusted the same way an entity
676 // id is. Comments inherit their parent's reach: a thread on a private,
677 // draft, password-protected or internal-CPT post is not this user's to
678 // read, and the envelope below would otherwise echo its title back.
679 if ( ! openstation_ai_can_read_post( $post_id ) ) {
680 return array(
681 'tool' => 'search_comments_by_post',
682 'post_id' => $post_id,
683 'offset' => $offset,
684 'items' => array(),
685 'count' => 0,
686 'total' => 0,
687 'has_more' => false,
688 'error' => 'Post not found or not readable.',
689 );
690 }
691
692 $base_args = array(
693 'post_id' => $post_id,
694 'status' => 'approve',
695 'type' => 'comment',
696 'search' => (string) $query,
697 );
698
699 $comments = get_comments(
700 array_merge(
701 $base_args,
702 array(
703 'number' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
704 'offset' => $offset,
705 'count' => false,
706 )
707 )
708 );
709
710 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
711
712 // Readable, per the gate above.
713 $parent_post = get_post( $post_id );
714 $parent_title = wp_strip_all_tags( $parent_post->post_title );
715
716 $items = array();
717 foreach ( $comments as $comment ) {
718 $items[] = array(
719 'id' => (int) $comment->comment_ID,
720 'type' => 'comment',
721 'post_id' => $post_id,
722 'post_title' => $parent_title,
723 'author_name' => openstation_plain_text_title( get_comment_author( $comment ) ),
724 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content, $query ),
725 'url' => (string) get_comment_link( $comment ),
726 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
727 );
728 }
729
730 return array(
731 'tool' => 'search_comments_by_post',
732 'post_id' => $post_id,
733 'post_title' => $parent_title,
734 'query' => (string) $query,
735 'offset' => $offset,
736 'items' => $items,
737 'count' => count( $items ),
738 'total' => $total,
739 'has_more' => ( $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE ) < $total,
740 'next_offset' => $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE,
741 );
742 }
743
744 // ---------------------------------------------------------------------------
745 // Entity detail builder — final REST response
746 // ---------------------------------------------------------------------------
747
748 /**
749 * Returns the full entity record used in the `entity` field of the REST
750 * response. All URLs are included so the UI can render direct links.
751 *
752 * Built entirely from core post/comment fields — no AI analysis meta is
753 * required. Comments opportunistically surface the `spam` / `harmful`
754 * verdict when the comment-moderation analysis happens to have run, but
755 * its absence never blocks the entity from being returned.
756 *
757 * The id arrives from the MODEL's final answer, and model output is
758 * untrusted — a search turn can be driven by attacker-controlled content, so
759 * an injected instruction could name an entity the search tools never
760 * surfaced. Hydration therefore re-checks readability itself instead of
761 * trusting that the id came out of a filtered tool result: posts/pages go
762 * through {@see openstation_ai_can_read_post()}, and so does a comment's
763 * PARENT, because the comment record carries that post's title and permalink
764 * — approval is a moderation decision, not a visibility one, and an approved
765 * comment outlives its post being switched to private or back to draft.
766 * Reading an unapproved comment needs `edit_comment`, mirroring Core's
767 * `WP_REST_Comments_Controller::check_read_permission()`; the AI moderation
768 * verdicts and the wp-admin edit link are narrower still. Unreadable ids
769 * resolve to null, indistinguishable from nonexistent ones.
770 *
771 * @param string $entity_type 'post' | 'page' | 'comment'.
772 * @param int $entity_id
773 * @return array|null
774 */
775 function openstation_ai_search_build_entity( $entity_type, $entity_id ) {
776 $entity_id = (int) $entity_id;
777
778 if ( in_array( $entity_type, array( 'post', 'page' ), true ) ) {
779 $post = get_post( $entity_id );
780
781 // The id must resolve to an actual post or page. The gate below answers
782 // type visibility on its own, so this is the contract rather than the
783 // lock: the record's `type` is what the client renders the card from,
784 // and post/page is what the search tools surface. A viewable CPT row
785 // would pass the gate and still have no card to land in.
786 if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
787 return null;
788 }
789
790 if ( ! openstation_ai_can_read_post( $post ) ) {
791 return null;
792 }
793
794 return array(
795 'id' => $entity_id,
796 'type' => $post->post_type,
797 'title' => openstation_plain_text_title( $post->post_title ),
798 'status' => $post->post_status,
799 'date' => $post->post_date ? substr( $post->post_date, 0, 10 ) : '',
800 'url' => (string) get_permalink( $post ),
801 'edit_url' => (string) get_edit_post_link( $entity_id, 'raw' ),
802 'excerpt' => openstation_ai_search_excerpt( $post->post_content ),
803 );
804 }
805
806 if ( 'comment' === $entity_type ) {
807 $comment = get_comment( $entity_id );
808
809 // The parent's reach bounds the comment's: approval is a moderation
810 // decision, not a visibility one, and this record carries the parent's
811 // title and permalink — so without this check, naming a comment id
812 // would walk straight around the post branch's gate above.
813 if ( ! $comment instanceof WP_Comment || ! openstation_ai_can_read_comment_parent( $comment ) ) {
814 return null;
815 }
816
817 // Reading an unapproved comment is an editor's business, per Core's
818 // WP_REST_Comments_Controller::check_read_permission().
819 if ( '1' !== (string) $comment->comment_approved && ! current_user_can( 'edit_comment', $entity_id ) ) {
820 return null;
821 }
822
823 // The AI verdicts are the moderation queue's data, so they follow the
824 // moderation capability rather than the per-comment edit one.
825 $can_moderate = current_user_can( 'moderate_comments' );
826 $parent_post = get_post( (int) $comment->comment_post_ID );
827
828 $meta = $can_moderate ? openstation_ai_get_meta( 'comment', $entity_id ) : null;
829 $entity = array(
830 'id' => $entity_id,
831 'type' => 'comment',
832 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
833 'post_id' => (int) $comment->comment_post_ID,
834 'post_title' => openstation_plain_text_title( $parent_post->post_title ),
835 'post_url' => (string) get_permalink( $parent_post ),
836 'url' => (string) get_comment_link( $comment ),
837 'edit_url' => current_user_can( 'edit_comment', $entity_id )
838 ? admin_url( 'comment.php?action=editcomment&c=' . $entity_id )
839 : '',
840 );
841
842 // Moderation verdicts are for moderators only.
843 if ( $can_moderate ) {
844 $entity['harmful'] = $meta ? (bool) ( $meta['harmful'] ?? false ) : false;
845 $entity['spam'] = $meta ? (bool) ( $meta['spam'] ?? false ) : false;
846 }
847
848 return $entity;
849 }
850
851 return null;
852 }
853
854 // ---------------------------------------------------------------------------
855 // Agentic search loop
856 // ---------------------------------------------------------------------------
857
858 /**
859 * Returns the label for the "keep looking" button on an exhausted search.
860 *
861 * One full sentence per resumable tool: a noun interpolated into a shared
862 * template cannot be translated.
863 *
864 * @param string $resume_tool Tool the client would resume from.
865 * @param int $from_item 1-based index of the next item to search.
866 * @return string
867 */
868 function openstation_ai_continue_label( $resume_tool, $from_item ) {
869 switch ( $resume_tool ) {
870 case 'search_pages':
871 /* translators: %d: 1-based index of the next page to search. */
872 return sprintf( __( 'Continue searching in pages (from item %d)', 'desktop-mode' ), $from_item );
873 case 'search_comments':
874 /* translators: %d: 1-based index of the next comment to search. */
875 return sprintf( __( 'Continue searching in comments (from item %d)', 'desktop-mode' ), $from_item );
876 default:
877 /* translators: %d: 1-based index of the next post to search. */
878 return sprintf( __( 'Continue searching in posts (from item %d)', 'desktop-mode' ), $from_item );
879 }
880 }
881
882 /**
883 * Returns the tools a client may resume an exhausted search from.
884 *
885 * Single source of truth for every `resume_tool` allowlist: the REST
886 * arg sanitizer and the `$initial_tool` validation inside
887 * `openstation_ai_run_search()`. `search_comments_by_post` is
888 * deliberately absent: the `continue` payload carries no `post_id`, so
889 * it cannot truly resume — exhausted runs map it to `search_comments`
890 * when building the `continue` object.
891 *
892 * @return string[] Tool names.
893 */
894 function openstation_ai_search_resumable_tools() {
895 return array( 'search_posts', 'search_pages', 'search_comments' );
896 }
897
898 /**
899 * Projects a tool's `parameters` schema onto the provider-supported subset.
900 *
901 * The Abilities API (and plugin-supplied tools) may use the full breadth of JSON
902 * Schema, but the provider's tool-schema validator does not — and it rejects the
903 * whole request, not just the offending tool, so ONE tool with a
904 * legal-but-unsupported schema makes the entire assistant return a 400 before the
905 * model runs. Three shapes that are valid JSON Schema, but rejected here, have
906 * been seen in the wild (see the linked issue):
907 *
908 * 1. `type` as an array, e.g. ['object','null'] — an ability's GET/null
909 * run-path. The provider wants the literal string "object" at the top level.
910 * 2. A top-level `oneOf` / `anyOf` / `allOf`, e.g. "post_id OR slug". Rejected
911 * with "does not support oneOf, allOf, or anyOf at the top level".
912 * 3. `properties` as an empty PHP array, which encodes to JSON as `[]` where an
913 * object schema needs `{}`.
914 *
915 * This reshapes only the copy advertised to the model. The ability itself is
916 * untouched: `WP_Ability::execute()` still validates arguments against the real
917 * schema and `permission_callback` still gates execution, so nothing loses
918 * enforcement — the model is simply told the constraint in prose (the tool
919 * description) instead of in a schema construct the provider can't parse. Only
920 * the TOP level is constrained; a combinator nested inside a property is a real
921 * constraint the provider accepts, so it is left intact.
922 *
923 * @param mixed $schema A tool parameters schema (array), or empty/non-array.
924 * @return array A provider-safe object schema for a tool `parameters` block.
925 */
926 function openstation_ai_normalize_tool_schema( $schema ) {
927 if ( ! is_array( $schema ) || empty( $schema ) ) {
928 return array(
929 'type' => 'object',
930 'properties' => (object) array(),
931 );
932 }
933
934 // Recursively drop the WordPress-only arg-schema keys
935 // (`sanitize_callback` / `validate_callback` / `arg_options`) —
936 // see the helper's docblock for why this must run at every depth.
937 $schema = openstation_ai_strip_wp_schema_keys( $schema );
938
939 // Top-level tool parameters must be the literal "object", never a union.
940 $schema['type'] = 'object';
941
942 // Strip top-level combinators — the provider rejects them outright, and one
943 // such tool 400s the whole request. Nested combinators are left alone.
944 unset( $schema['oneOf'], $schema['allOf'], $schema['anyOf'] );
945
946 // An empty PHP array encodes as `[]`; an object schema's properties need `{}`.
947 // A schema with no `properties` at all (e.g. one whose only content was a
948 // stripped top-level combinator) gets an empty object for the same reason.
949 if ( ! isset( $schema['properties'] ) || array() === $schema['properties'] ) {
950 $schema['properties'] = (object) array();
951 }
952
953 return $schema;
954 }
955
956 /**
957 * Recursively removes the WordPress-only arg-schema keys from a tool schema.
958 *
959 * WordPress arg schemas legally extend JSON Schema with PHP-callable keys —
960 * `sanitize_callback`, `validate_callback`, and (on meta args) `arg_options`.
961 * Abilities registered from REST arg definitions carry them at every property
962 * level, and providers that validate tool schemas strictly reject any unknown
963 * field ("Invalid JSON payload received. Unknown name \"sanitize_callback\""),
964 * 400-ing the whole request over one property.
965 *
966 * The walk is structure-aware, not a blind key sweep: maps under `properties` /
967 * `patternProperties` are keyed by PROPERTY NAME, so a property that happens to
968 * be called `sanitize_callback` is preserved — only its schema value is
969 * cleaned. Recursion covers every position a subschema can occupy: property
970 * values, `items` (single schema or tuple list), array-shaped
971 * `additionalProperties`, and nested `oneOf` / `allOf` / `anyOf` branches
972 * (which are kept — only the TOP level of the tool schema strips combinators).
973 *
974 * @param array $schema A tool parameters (sub)schema.
975 * @return array The schema without WP-only keys, at any depth.
976 */
977 function openstation_ai_strip_wp_schema_keys( array $schema ) {
978 unset( $schema['sanitize_callback'], $schema['validate_callback'], $schema['arg_options'] );
979
980 foreach ( array( 'properties', 'patternProperties' ) as $map_key ) {
981 if ( isset( $schema[ $map_key ] ) && is_array( $schema[ $map_key ] ) ) {
982 foreach ( $schema[ $map_key ] as $name => $sub ) {
983 if ( is_array( $sub ) ) {
984 $schema[ $map_key ][ $name ] = openstation_ai_strip_wp_schema_keys( $sub );
985 }
986 }
987 }
988 }
989
990 if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
991 $items = $schema['items'];
992 $is_list = array_keys( $items ) === range( 0, count( $items ) - 1 );
993 if ( $is_list && array() !== $items ) {
994 // Tuple form — a list of schemas.
995 foreach ( $items as $i => $sub ) {
996 if ( is_array( $sub ) ) {
997 $items[ $i ] = openstation_ai_strip_wp_schema_keys( $sub );
998 }
999 }
1000 $schema['items'] = $items;
1001 } else {
1002 $schema['items'] = openstation_ai_strip_wp_schema_keys( $items );
1003 }
1004 }
1005
1006 if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
1007 $schema['additionalProperties'] = openstation_ai_strip_wp_schema_keys( $schema['additionalProperties'] );
1008 }
1009
1010 foreach ( array( 'oneOf', 'allOf', 'anyOf' ) as $combinator ) {
1011 if ( isset( $schema[ $combinator ] ) && is_array( $schema[ $combinator ] ) ) {
1012 foreach ( $schema[ $combinator ] as $i => $sub ) {
1013 if ( is_array( $sub ) ) {
1014 $schema[ $combinator ][ $i ] = openstation_ai_strip_wp_schema_keys( $sub );
1015 }
1016 }
1017 }
1018 }
1019
1020 return $schema;
1021 }
1022
1023 /**
1024 * Runs the agentic content-search loop.
1025 *
1026 * The model receives focused tools — search_posts, search_pages,
1027 * search_comments, search_comments_by_post — and a system prompt that
1028 * guides it to choose the right one based on query semantics and pass the
1029 * distilled keywords as `query`. "Someone said congratulations" → it calls
1030 * search_comments. "I wrote about paella" → it calls search_posts. No
1031 * entity_type routing from the caller is needed for a fresh search.
1032 *
1033 * For continuation runs ($initial_tool + $start_offset > 0), the system
1034 * message primes the agent to resume from the last searched position with
1035 * the same keywords.
1036 *
1037 * @param string $query User's natural-language search.
1038 * @param string|null $initial_tool Tool name to resume from, or null for fresh search.
1039 * @param int $start_offset Offset to resume from (0 for fresh).
1040 * @param array $extra Extensibility context (command tools, prompt overrides, …).
1041 * @return array|WP_Error
1042 */
1043 function openstation_ai_run_search( $query, $initial_tool = null, $start_offset = 0, array $extra = array() ) {
1044 $start_offset = max( 0, (int) $start_offset );
1045 $search_tools = array( 'search_posts', 'search_pages', 'search_comments', 'search_comments_by_post' );
1046 $valid_tools = array_merge(
1047 $search_tools,
1048 array( 'list_admin_pages', 'search_wporg_plugins', 'get_php_error_log' )
1049 );
1050
1051 // -----------------------------------------------------------------------
1052 // Extensibility context — command tools from the client, PHP-registered
1053 // tools from the server-side registry, system-prompt overrides, and a
1054 // per-call request_id for observability fanout.
1055 // -----------------------------------------------------------------------
1056 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
1057 $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && '' !== $extra['request_id']
1058 ? (string) $extra['request_id']
1059 : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ) );
1060 $command_tools_raw = isset( $extra['command_tools'] ) && is_array( $extra['command_tools'] ) ? $extra['command_tools'] : array();
1061 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
1062 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1063 ? (string) $extra['system_prompt_mode']
1064 : 'append';
1065
1066 /**
1067 * Fires once per `/ai/search` invocation, after validation and
1068 * before any the provider call. First anchor in the observability trio
1069 * (`openstation_ai_search_started` / `openstation_ai_tool_called`
1070 * / `openstation_ai_search_completed`).
1071 *
1072 * @param array $context {
1073 * @type string $query User query.
1074 * @type int $user_id
1075 * @type string $request_id UUID correlating the whole run.
1076 * }
1077 */
1078 do_action(
1079 'openstation_ai_search_started',
1080 array(
1081 'query' => $query,
1082 'user_id' => $user_id,
1083 'request_id' => $request_id,
1084 )
1085 );
1086
1087 if ( null !== $initial_tool && ! in_array( $initial_tool, openstation_ai_search_resumable_tools(), true ) ) {
1088 $initial_tool = null;
1089 }
1090
1091 // When resuming a previous exhausted run, prime the model with the
1092 // starting position so it doesn't waste iterations on already-searched
1093 // content.
1094 $continuation_note = '';
1095 if ( null !== $initial_tool && ( $start_offset > 0 || 'search_posts' !== $initial_tool ) ) {
1096 $continuation_note = sprintf(
1097 "\n\nNote: This is a continuation of a previous search. Begin with %s using the same search keywords at offset=%d and work forward.",
1098 $initial_tool,
1099 $start_offset
1100 );
1101 }
1102
1103 $instructions = "
1104 You are a friendly, conversational assistant embedded in a WordPress site. You help the site owner:
1105
1106 1. **Find content** they've written (posts, pages, comments) by describing it in natural language.
1107 2. **Navigate wp-admin** when they ask where to find something (\"where are the categories?\", \"how do I manage users?\").
1108 3. **Recommend plugins** from the official WordPress.org directory when they need extra functionality.
1109 4. **Check the site's error log** when they're troubleshooting something.
1110 5. **Answer anything else your tools can** — you may have more tools than the ones named here (WordPress and other plugins register their own, e.g. site / user / environment / version info). Your actual tool list is authoritative: whenever a tool can answer the request, call it and summarise the result, even if it isn't named here.
1111 6. **Chat** — only when no tool fits, answer conversationally.
1112
1113 Tone: warm, concise, helpful. First person (\"I found this post…\", \"Here's where you'll find that…\"). Not a search engine tone — no \"Match found\" or robot phrasing.
1114
1115 How to work the tools (your actual tool list is authoritative; use any tool that fits the request):
1116 - Content lookups: a search only returns items that contain every word of `query`, so search for one distinctive word at a time (\"autumn\", not \"autumn spiced recipe\"). When the request offers several candidate words, call the tool once per word in the same turn instead of one after another.
1117 - Every item a keyword search returns really contains that keyword, and its excerpt shows where. Answer from those results; don't page past them looking for a better one. One item fits → answer_type \"entity\". Two or more fit → answer_type \"navigation\" with up to 3 admin_links built from the results (title = the item's title, url = its edit_url, description = its date and a few words on why it matches, icon = \"dashicons-admin-post\" for posts, \"dashicons-admin-page\" for pages, \"dashicons-admin-comments\" for comments). Only when a search returns nothing, try another word.
1118 - Listing and counting: questions about who, how many or the latest (\"who has commented?\", \"how many posts do I have?\", \"latest comments\") are not keyword searches. Call the matching search_* tool once with an empty `query`, then answer from its items and `total` with answer_type \"chat\". Never search for words from the question itself, like \"comment\" or \"created\". Never use an empty `query` to look for a topic: it lists everything, newest first.
1119 - Plugin recommendations: present the best 3-5 as admin_links titled like \"Plugin Name · 5M+ installs · 4.8�
1120 \".
1121 - Error logs: summarise the most important errors first (fatal, then warnings, then notices) instead of copying entries.
1122
1123 Choosing which track:
1124 - \"I remember a post/page/comment about X\" → the corresponding search_* tool.
1125 - \"where can I find X?\", \"how do I manage Y?\", \"create/add/new …\", \"take me to …\", \"open …\", \"switch/activate …\", or any navigate/do intent → list_admin_pages, then suggest the 1-3 best destinations as admin_links (answer_type \"navigation\"). You suggest the link; the user opens it — never assume it's opened.
1126 - \"plugin for X\" / \"recommend a plugin\" → search_wporg_plugins → present as admin_links.
1127 - \"any errors?\" / \"check logs\" / troubleshooting → get_php_error_log → summarise in chat.
1128 - Any other factual question about the site (its version, PHP/environment, the current user, or anything one of your other tools covers) → call that tool, then summarise its result with answer_type \"chat\".
1129 - Greeting, unclear, or chit-chat → answer_type \"chat\" with a brief helpful message (no tools needed).
1130
1131 Always return one of three answer_type values in the structured output:
1132 - \"entity\": you identified a single post/page/comment. Fill entity_id + entity_type. admin_links = null.
1133 - \"navigation\": you're recommending admin pages, plugin install links, OR several posts/pages/comments that match. Fill admin_links. entity_id + entity_type = null.
1134 - \"chat\": you're answering conversationally — including results summarised from any tool (error logs, environment/version info, other plugins' tools), greetings, and \"nothing found\" answers. entity_id + entity_type + admin_links all null.
1135
1136 The message field is always a friendly sentence or two shown directly to the user. Make it sound like a person, not a log line.
1137 ";
1138
1139 if ( $continuation_note ) {
1140 $instructions .= $continuation_note;
1141 }
1142
1143 // -----------------------------------------------------------------------
1144 // System-prompt extensibility. All three layers — appendix filter,
1145 // client override (append/replace with capability gate), and final
1146 // transform — live in `openstation_ai_compose_instructions()` so the
1147 // primary run and the follow-up leg stay in lockstep. See the
1148 // helper for the order of application; the filter docblocks at its
1149 // `apply_filters()` call sites carry the public contract on each
1150 // extension point.
1151 // -----------------------------------------------------------------------
1152 $prompt_context = array(
1153 'query' => $query,
1154 'user_id' => $user_id,
1155 'request_id' => $request_id,
1156 );
1157
1158 $instructions = openstation_ai_compose_instructions(
1159 $instructions,
1160 $prompt_context,
1161 array(
1162 'text' => $system_prompt_text,
1163 'mode' => $system_prompt_mode,
1164 )
1165 );
1166
1167 // -----------------------------------------------------------------------
1168 // Tool assembly — built-in search/navigation abilities + client-supplied
1169 // command tools.
1170 //
1171 // Built-in tools are WordPress Abilities API abilities. Each is advertised
1172 // to the model as a function declaration named after the ability (namespace
1173 // stripped), and $ability_by_tool maps that name back to the ability so the
1174 // agent loop can resolve + execute() it (permission + input/output
1175 // validation happen inside execute()).
1176 // -----------------------------------------------------------------------
1177 $ability_by_tool = array();
1178 $builtin_tools = array();
1179
1180 // Names are de-duplicated across namespaces up front: providers 400 the
1181 // whole request when two tools share a name.
1182 foreach ( openstation_ai_unique_ability_tool_names( openstation_ai_search_ability_names() ) as $tool_name => $ability_name ) {
1183 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
1184 if ( ! $ability instanceof WP_Ability ) {
1185 continue;
1186 }
1187
1188 $ability_by_tool[ $tool_name ] = $ability_name;
1189 $valid_tools[] = $tool_name;
1190
1191 $input_schema = $ability->get_input_schema();
1192 $builtin_tools[] = array(
1193 'type' => 'function',
1194 'name' => $tool_name,
1195 'description' => (string) $ability->get_description(),
1196 'parameters' => ! empty( $input_schema )
1197 ? $input_schema
1198 : array(
1199 'type' => 'object',
1200 'properties' => (object) array(),
1201 ),
1202 );
1203 }
1204
1205 // Command tools — namespaced as `command_<slug>` on the server so
1206 // they can't collide with built-in tool names. Each takes a single
1207 // optional `args` string arg (matches the slash-command contract
1208 // where args are a single string the plugin's `run()` parses).
1209 $command_tools_by_name = array();
1210 $command_defs = array();
1211
1212 foreach ( $command_tools_raw as $cmd ) {
1213 if ( ! is_array( $cmd ) ) {
1214 continue;
1215 }
1216 $slug = isset( $cmd['slug'] ) ? (string) $cmd['slug'] : '';
1217 if ( '' === $slug || ! preg_match( '/^[a-z0-9_\-]+$/', $slug ) ) {
1218 continue;
1219 }
1220 /**
1221 * Per-tool filter on the client-supplied command list. Return
1222 * `false` to drop a command entirely before it reaches the
1223 * model — the right hook for per-role / per-command gating.
1224 *
1225 * @param bool|array $allowed Either the (possibly mutated) command
1226 * tool entry, or `false` to drop it.
1227 * @param string $slug Command slug.
1228 * @param array $context { user_id, request_id }.
1229 */
1230 $allowed = apply_filters(
1231 'openstation_ai_command_allowed',
1232 $cmd,
1233 $slug,
1234 array(
1235 'user_id' => $user_id,
1236 'request_id' => $request_id,
1237 )
1238 );
1239 if ( false === $allowed || ! is_array( $allowed ) ) {
1240 continue;
1241 }
1242 $label = isset( $allowed['label'] ) ? (string) $allowed['label'] : $slug;
1243 $description = isset( $allowed['description'] ) ? (string) $allowed['description'] : '';
1244 $hint = isset( $allowed['hint'] ) ? (string) $allowed['hint'] : '';
1245 $tool_name = 'command_' . $slug;
1246
1247 $command_tools_by_name[ $tool_name ] = array( 'slug' => $slug );
1248 $command_defs[] = array(
1249 'type' => 'function',
1250 'name' => $tool_name,
1251 'description' => trim( $label . ( '' !== $description ? ' — ' . $description : '' ) ),
1252 'parameters' => array(
1253 'type' => 'object',
1254 'properties' => array(
1255 'args' => array(
1256 'type' => 'string',
1257 'description' => '' !== $hint
1258 ? sprintf( 'Arguments for this command. Hint: %s', $hint )
1259 : 'Arguments for this command. Leave empty when the command takes none.',
1260 ),
1261 ),
1262 'required' => array( 'args' ),
1263 'additionalProperties' => false,
1264 ),
1265 );
1266 }
1267
1268 /**
1269 * Transform the command-tool subset before merging with the
1270 * built-in + registered tools. Useful for bulk gating, renaming,
1271 * or injecting synthetic command tools.
1272 *
1273 * @param array $command_defs Command tool definitions.
1274 * @param array $context { user_id, request_id }.
1275 */
1276 $command_defs = (array) apply_filters(
1277 'openstation_ai_command_tools',
1278 $command_defs,
1279 array(
1280 'user_id' => $user_id,
1281 'request_id' => $request_id,
1282 )
1283 );
1284
1285 $tools = array_merge( $builtin_tools, $command_defs );
1286
1287 /**
1288 * Transform the full tool list (built-in abilities + command tools) just
1289 * before it goes to the provider. Fires once per run — changes apply to
1290 * every iteration in the agent loop.
1291 *
1292 * @param array $tools Full the provider tool definitions array.
1293 * @param array $context { user_id, request_id, query }.
1294 */
1295 $tools = (array) apply_filters(
1296 'openstation_ai_tools',
1297 $tools,
1298 array(
1299 'user_id' => $user_id,
1300 'request_id' => $request_id,
1301 'query' => $query,
1302 )
1303 );
1304
1305 // Normalize every tool's schema onto the provider-supported subset, AFTER the
1306 // filter so it covers the complete list the provider will receive — built-in
1307 // abilities, command tools, and anything a plugin injected. One tool with a
1308 // legal-but-unsupported schema otherwise 400s the entire request, not just its
1309 // own tool. Only the model-facing copy is reshaped; abilities still validate
1310 // arguments against their real schema in execute(). Idempotent, so a plugin
1311 // that already normalizes on the filter above is unaffected.
1312 foreach ( $tools as $ti => $tool ) {
1313 if ( is_array( $tool ) && isset( $tool['parameters'] ) ) {
1314 $tools[ $ti ]['parameters'] = openstation_ai_normalize_tool_schema( $tool['parameters'] );
1315 }
1316 }
1317
1318 // Backstop for the unique-name rule: a filter can inject a tool twice, or
1319 // a command tool can shadow an ability named `command_*`. Providers reject
1320 // the whole request over one repeat, so keep the first tool of each name.
1321 $seen_tool_names = array();
1322 foreach ( $tools as $ti => $tool ) {
1323 $name = is_array( $tool ) && isset( $tool['name'] ) ? (string) $tool['name'] : '';
1324 if ( '' === $name ) {
1325 continue;
1326 }
1327 if ( isset( $seen_tool_names[ $name ] ) ) {
1328 unset( $tools[ $ti ] );
1329 continue;
1330 }
1331 $seen_tool_names[ $name ] = true;
1332 }
1333 $tools = array_values( $tools );
1334
1335 // Widen the permitted-tools list with the command tools — the agent loop
1336 // rejects any `function_call` whose name isn't in here (built-in ability
1337 // names were added above).
1338 foreach ( $command_defs as $def ) {
1339 if ( isset( $def['name'] ) ) {
1340 $valid_tools[] = (string) $def['name'];
1341 }
1342 }
1343
1344 $answer_schema = openstation_ai_search_answer_schema();
1345
1346 // -----------------------------------------------------------------------
1347 // First call — user query as the sole message, instructions as system
1348 // guidance. Generation routes through the WordPress AI Client; the tools
1349 // are advertised as function declarations and dispatched by this loop.
1350 // The full ordered conversation is rebuilt and re-sent each turn.
1351 // -----------------------------------------------------------------------
1352 $messages = array( openstation_ai_user_text_message( $query ) );
1353
1354 $generation_context = array(
1355 'source' => 'ai-copilot/search',
1356 'request_id' => $request_id,
1357 );
1358
1359 $turn = openstation_ai_search_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1360
1361 if ( is_wp_error( $turn ) ) {
1362 return $turn;
1363 }
1364
1365 $last_tool = $initial_tool ?? 'search_posts';
1366 $last_offset = $start_offset;
1367 $last_has_more = true;
1368 $iterations = 0;
1369
1370 // Accumulate token usage across every turn and remember the last model the
1371 // AI Client resolved, for the `openstation_ai_search_completed` payload.
1372 $total_usage = array(
1373 'prompt' => 0,
1374 'completion' => 0,
1375 'total' => 0,
1376 );
1377 $last_model = null;
1378 $accrue_usage = static function ( $turn ) use ( &$total_usage, &$last_model ) {
1379 if ( ! is_array( $turn ) ) {
1380 return;
1381 }
1382 if ( isset( $turn['usage'] ) && is_array( $turn['usage'] ) ) {
1383 $total_usage['prompt'] += (int) ( $turn['usage']['prompt'] ?? 0 );
1384 $total_usage['completion'] += (int) ( $turn['usage']['completion'] ?? 0 );
1385 $total_usage['total'] += (int) ( $turn['usage']['total'] ?? 0 );
1386 }
1387 if ( isset( $turn['model'] ) && is_array( $turn['model'] ) ) {
1388 $last_model = $turn['model'];
1389 }
1390 };
1391 $accrue_usage( $turn );
1392
1393 // Turns a toolless model turn into the final response. Shared by the
1394 // loop and the wrap-up turn below, so both answer the same way.
1395 $finish = static function ( $answer_turn, $continue ) use ( $query, $user_id, $request_id, &$iterations, &$last_has_more, &$total_usage, &$last_model ) {
1396 // A toolless turn with no extractable text never reaches here:
1397 // openstation_ai_client_generate() returns
1398 // `openstation_ai_empty_answer` for that case, handled with the
1399 // other generation errors above.
1400 $text = (string) ( $answer_turn['text'] ?? '' );
1401
1402 $answer = json_decode( $text, true );
1403 if ( ! is_array( $answer ) ) {
1404 return new WP_Error( 'openstation_ai_result_parse', __( 'Could not parse structured search answer.', 'desktop-mode' ) );
1405 }
1406
1407 $answer_type = isset( $answer['answer_type'] ) && in_array( $answer['answer_type'], array( 'entity', 'navigation', 'chat' ), true )
1408 ? (string) $answer['answer_type']
1409 : 'chat';
1410 $message = isset( $answer['message'] ) ? (string) $answer['message'] : '';
1411 $entity_id = ( isset( $answer['entity_id'] ) && is_int( $answer['entity_id'] ) )
1412 ? $answer['entity_id'] : null;
1413 $entity_type = ( isset( $answer['entity_type'] ) && is_string( $answer['entity_type'] ) )
1414 ? $answer['entity_type'] : null;
1415 $admin_links = isset( $answer['admin_links'] ) && is_array( $answer['admin_links'] )
1416 ? $answer['admin_links'] : null;
1417
1418 $entity = null;
1419 if ( 'entity' === $answer_type && $entity_id && $entity_type ) {
1420 $entity = openstation_ai_search_build_entity( $entity_type, $entity_id );
1421 }
1422
1423 $final = array(
1424 'answer_type' => $answer_type,
1425 'message' => $message,
1426 'entity' => $entity,
1427 'admin_links' => $admin_links,
1428 'iterations' => $iterations + 1,
1429 'exhausted' => ! $last_has_more,
1430 'continue' => $continue,
1431 'request_id' => $request_id,
1432 );
1433
1434 /**
1435 * Final transform hook — fires right before the HTTP
1436 * response is returned. Plugins can rewrite `message`,
1437 * inject `admin_links`, coerce `answer_type`, etc.
1438 *
1439 * @param array $answer Final answer payload.
1440 * @param array $context { query, user_id, request_id }.
1441 */
1442 $final = (array) apply_filters(
1443 'openstation_ai_answer',
1444 $final,
1445 array(
1446 'query' => $query,
1447 'user_id' => $user_id,
1448 'request_id' => $request_id,
1449 )
1450 );
1451
1452 do_action(
1453 'openstation_ai_search_completed',
1454 array(
1455 'query' => $query,
1456 'user_id' => $user_id,
1457 'request_id' => $request_id,
1458 'answer_type' => $final['answer_type'] ?? 'chat',
1459 'iterations' => $final['iterations'] ?? 0,
1460 'usage' => $total_usage,
1461 'model' => $last_model,
1462 )
1463 );
1464
1465 return $final;
1466 };
1467
1468 // Every tool result, so a run that ends without an answer can still be
1469 // answered from what it found (see the wrap-up turn after the loop).
1470 $gathered = array();
1471
1472 // -----------------------------------------------------------------------
1473 // Agentic loop — each iteration either executes tool calls or returns
1474 // the final answer. The full ordered conversation (user query, assistant
1475 // turns, tool results) is accumulated in $messages and re-sent each turn.
1476 // -----------------------------------------------------------------------
1477 // `<=`: the last round's results produce one more turn, and that turn
1478 // may be the answer. With `<` it was generated and never read.
1479 for ( $i = 0; $i <= OPENSTATION_AI_SEARCH_MAX_ITERATIONS; $i++ ) {
1480 $function_calls = is_array( $turn['function_calls'] ?? null ) ? $turn['function_calls'] : array();
1481
1482 // No tool calls in this response → final answer.
1483 if ( empty( $function_calls ) ) {
1484 return $finish( $turn, null );
1485 }
1486
1487 // -------------------------------------------------------------------
1488 // Command-tool short-circuit.
1489 //
1490 // If the model emitted `command_<slug>`, we return immediately with
1491 // `answer_type: 'tool_call'` — the client owns the command's `run()`
1492 // function (lives in plugin JS) and executes it locally. We do NOT
1493 // send anything else back to the provider this turn — would burn tokens
1494 // for a no-op second response.
1495 // -------------------------------------------------------------------
1496 $command_tool_call = null;
1497 foreach ( $function_calls as $fc ) {
1498 $name = (string) ( $fc['name'] ?? '' );
1499 if ( isset( $command_tools_by_name[ $name ] ) ) {
1500 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1501 $decoded = is_array( $raw ) ? $raw : array();
1502 $command_tool_call = array(
1503 'slug' => $command_tools_by_name[ $name ]['slug'],
1504 'args' => isset( $decoded['args'] ) ? (string) $decoded['args'] : '',
1505 );
1506 break;
1507 }
1508 }
1509 if ( null !== $command_tool_call ) {
1510 do_action(
1511 'openstation_ai_tool_called',
1512 array(
1513 'tool_name' => 'command_' . $command_tool_call['slug'],
1514 'args' => array( 'args' => $command_tool_call['args'] ),
1515 'user_id' => $user_id,
1516 'request_id' => $request_id,
1517 )
1518 );
1519
1520 $final = array(
1521 'answer_type' => 'tool_call',
1522 'message' => '',
1523 'entity' => null,
1524 'admin_links' => null,
1525 'tool' => $command_tool_call,
1526 'iterations' => $iterations + 1,
1527 'exhausted' => false,
1528 'continue' => null,
1529 'request_id' => $request_id,
1530 );
1531
1532 $final = (array) apply_filters(
1533 'openstation_ai_answer',
1534 $final,
1535 array(
1536 'query' => $query,
1537 'user_id' => $user_id,
1538 'request_id' => $request_id,
1539 )
1540 );
1541
1542 do_action(
1543 'openstation_ai_search_completed',
1544 array(
1545 'query' => $query,
1546 'user_id' => $user_id,
1547 'request_id' => $request_id,
1548 'answer_type' => 'tool_call',
1549 'iterations' => $final['iterations'] ?? 0,
1550 'usage' => $total_usage,
1551 'model' => $last_model,
1552 )
1553 );
1554
1555 return $final;
1556 }
1557
1558 // Out of rounds: the model still wants tools. The wrap-up turn after
1559 // the loop answers from what it has gathered instead.
1560 if ( $i >= OPENSTATION_AI_SEARCH_MAX_ITERATIONS ) {
1561 break;
1562 }
1563
1564 // Execute each tool call and collect results as
1565 // `{ call_id, name, response }` — turned into FunctionResponse parts
1566 // for the next turn by openstation_ai_tool_result_message().
1567 $tool_outputs = array();
1568 foreach ( $function_calls as $fc ) {
1569 $tool_name = $fc['name'] ?? '';
1570 $call_id = $fc['call_id'] ?? '';
1571
1572 if ( ! in_array( $tool_name, $valid_tools, true ) ) {
1573 $tool_outputs[] = array(
1574 'call_id' => $call_id,
1575 'name' => $tool_name,
1576 'response' => array( 'error' => "Unknown tool '{$tool_name}'." ),
1577 );
1578 continue;
1579 }
1580
1581 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1582 $args = is_array( $raw ) ? $raw : array();
1583 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
1584
1585 do_action(
1586 'openstation_ai_tool_called',
1587 array(
1588 'tool_name' => $tool_name,
1589 'args' => $args,
1590 'user_id' => $user_id,
1591 'request_id' => $request_id,
1592 )
1593 );
1594
1595 // Resolve + run the ability. execute() runs the permission_callback
1596 // and validates input/output; a denial or bad input comes back as a
1597 // WP_Error, which we surface to the model as a clean tool error
1598 // (never a fatal) and report on the observability channel.
1599 $ability = isset( $ability_by_tool[ $tool_name ] ) ? wp_get_ability( $ability_by_tool[ $tool_name ] ) : null;
1600 if ( $ability instanceof WP_Ability ) {
1601 // Abilities that declare no input schema (e.g. Core's
1602 // get-*-info) reject any non-null input, so pass null when
1603 // there's no schema; otherwise hand over the decoded args.
1604 $input = empty( $ability->get_input_schema() ) ? null : $args;
1605 $result = $ability->execute( $input );
1606 } else {
1607 $result = new WP_Error( 'openstation_ai_unknown_ability', sprintf( 'Ability for tool "%s" is unavailable.', $tool_name ) );
1608 }
1609
1610 if ( is_wp_error( $result ) ) {
1611 do_action(
1612 'openstation_ai_search_error',
1613 array(
1614 'stage' => 'tool_execute',
1615 'tool_name' => $tool_name,
1616 'error' => $result->get_error_code(),
1617 'message' => $result->get_error_message(),
1618 'user_id' => $user_id,
1619 'request_id' => $request_id,
1620 )
1621 );
1622 $batch = array(
1623 'error' => $result->get_error_message(),
1624 'error_code' => $result->get_error_code(),
1625 );
1626 } else {
1627 $batch = is_array( $result ) ? $result : array( 'result' => $result );
1628 }
1629
1630 $last_tool = $tool_name;
1631 $last_offset = $offset;
1632 $last_has_more = (bool) ( $batch['has_more'] ?? false );
1633
1634 /**
1635 * Transform a tool result before it goes back to the model.
1636 * Fires for every ability-dispatched tool (including error
1637 * envelopes from a failed execute()).
1638 *
1639 * @param array $batch Tool result payload.
1640 * @param string $tool_name Tool function name.
1641 * @param array $args Decoded args from the call.
1642 * @param array $context { user_id, request_id }.
1643 */
1644 $batch = (array) apply_filters(
1645 'openstation_ai_tool_result',
1646 $batch,
1647 $tool_name,
1648 $args,
1649 array(
1650 'user_id' => $user_id,
1651 'request_id' => $request_id,
1652 )
1653 );
1654
1655 $tool_outputs[] = array(
1656 'call_id' => $call_id,
1657 'name' => $tool_name,
1658 'response' => $batch,
1659 );
1660 $gathered[] = array(
1661 'tool' => $tool_name,
1662 'args' => $args,
1663 'result' => $batch,
1664 );
1665 }
1666
1667 ++$iterations;
1668
1669 // Next turn — append the assistant's tool-call turn and our tool
1670 // results to the conversation, then regenerate with the full history.
1671 $messages[] = $turn['message'];
1672 $messages[] = openstation_ai_tool_result_message( $tool_outputs );
1673
1674 $turn = openstation_ai_search_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1675
1676 if ( is_wp_error( $turn ) ) {
1677 return $turn;
1678 }
1679 $accrue_usage( $turn );
1680 }
1681
1682 // -----------------------------------------------------------------------
1683 // Budget exhausted before a final answer.
1684 // -----------------------------------------------------------------------
1685 $continue = null;
1686 if ( $last_has_more ) {
1687 $next_offset = $last_offset + OPENSTATION_AI_SEARCH_BATCH_SIZE;
1688 // `search_comments_by_post` cannot resume — the continue payload
1689 // carries no post_id — so fall back to plain comment search,
1690 // keeping `tool` inside openstation_ai_search_resumable_tools().
1691 $resume_tool = 'search_comments_by_post' === $last_tool ? 'search_comments' : $last_tool;
1692 $continue = array(
1693 'tool' => $resume_tool,
1694 'entity_type' => rtrim( str_replace( 'search_', '', $resume_tool ), 's' ),
1695 'offset' => $next_offset,
1696 'label' => openstation_ai_continue_label( $resume_tool, $next_offset + 1 ),
1697 );
1698 }
1699
1700 // Wrap-up turn: answer from everything the run found. Without it, a model
1701 // that kept refining after a first search had already hit (e.g. searched
1702 // "performance", got the posts, then searched "direct") threw all of it
1703 // away for the stock "couldn't find" message. A fresh conversation with
1704 // no tools, because providers reject tool-call history with no tools.
1705 if ( ! empty( $gathered ) ) {
1706 $wrap_up = openstation_ai_search_generate(
1707 $user_id,
1708 array( openstation_ai_user_text_message( openstation_ai_search_wrap_up_prompt( $query, $gathered ) ) ),
1709 array(),
1710 $answer_schema,
1711 $instructions,
1712 array_merge( $generation_context, array( 'source' => 'ai-copilot/search-wrap-up' ) )
1713 );
1714 if ( ! is_wp_error( $wrap_up ) ) {
1715 $accrue_usage( $wrap_up );
1716 if ( empty( $wrap_up['function_calls'] ) ) {
1717 $answer = $finish( $wrap_up, $continue );
1718 if ( ! is_wp_error( $answer ) ) {
1719 return $answer;
1720 }
1721 }
1722 }
1723 }
1724
1725 $final = array(
1726 'answer_type' => 'chat',
1727 // The budget counts tool rounds, not items, so don't claim a number:
1728 // a round can return nothing at all.
1729 'message' => null !== $continue
1730 ? __( 'I couldn\'t find a clear match yet. Want me to keep looking further?', 'desktop-mode' )
1731 : __( 'I couldn\'t find a clear match. Try describing it another way?', 'desktop-mode' ),
1732 'entity' => null,
1733 'admin_links' => null,
1734 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1735 'exhausted' => ! $last_has_more,
1736 'continue' => $continue,
1737 'request_id' => $request_id,
1738 );
1739
1740 $final = (array) apply_filters(
1741 'openstation_ai_answer',
1742 $final,
1743 array(
1744 'query' => $query,
1745 'user_id' => $user_id,
1746 'request_id' => $request_id,
1747 )
1748 );
1749
1750 do_action(
1751 'openstation_ai_search_completed',
1752 array(
1753 'query' => $query,
1754 'user_id' => $user_id,
1755 'request_id' => $request_id,
1756 'answer_type' => 'chat',
1757 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1758 'usage' => $total_usage,
1759 'model' => $last_model,
1760 )
1761 );
1762
1763 return $final;
1764 }
1765
1766 /**
1767 * Runs one generation turn of the search loop.
1768 *
1769 * A thin pass-through to {@see openstation_ai_client_generate()} with a
1770 * pre-filter in front, so tests (and alternative runtimes) can script the
1771 * model's turns. Mirrors `openstation_agent_runner_generate`.
1772 *
1773 * @param int $user_id Requesting user id.
1774 * @param array $messages Ordered conversation as SDK Message objects.
1775 * @param array $tools Tool definitions; empty on the wrap-up turn.
1776 * @param array|null $answer_schema JSON Schema for the final answer.
1777 * @param string $instructions System instruction.
1778 * @param array $context `{ source, request_id }`.
1779 * @return array|WP_Error Same shape as openstation_ai_client_generate().
1780 */
1781 function openstation_ai_search_generate( $user_id, array $messages, array $tools, $answer_schema, $instructions, array $context ) {
1782 /**
1783 * Pre-filter one search generation turn. Return a non-null
1784 * `{ text, function_calls, message, usage, model }` array (or a
1785 * WP_Error) to short-circuit the Core AI Client.
1786 *
1787 * @param array|WP_Error|null $generated Null to proceed with the AI Client.
1788 * @param array $messages Ordered conversation.
1789 * @param array $tools Tool definitions; empty on the wrap-up turn.
1790 * @param array $context `{ source, request_id }`. `source` is
1791 * `ai-copilot/search-wrap-up` on the wrap-up turn.
1792 * @param int $user_id Requesting user id.
1793 */
1794 $generated = apply_filters( 'openstation_ai_search_generate', null, $messages, $tools, $context, (int) $user_id );
1795 if ( null !== $generated ) {
1796 return $generated;
1797 }
1798 return openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $context );
1799 }
1800
1801 /**
1802 * The single user message for the search wrap-up turn.
1803 *
1804 * Carries the user's request and every tool result the run gathered, so a
1805 * toolless turn can answer from them. Large runs are cut from the oldest
1806 * end: the latest results are the ones the model was refining toward.
1807 *
1808 * @param string $query The user's request.
1809 * @param array $gathered `[ { tool, args, result } ]` in call order.
1810 * @return string
1811 */
1812 function openstation_ai_search_wrap_up_prompt( $query, array $gathered ) {
1813 $budget = 60000;
1814 $entries = array();
1815 foreach ( array_reverse( $gathered ) as $entry ) {
1816 $json = (string) wp_json_encode( $entry );
1817 if ( $budget - strlen( $json ) < 0 && ! empty( $entries ) ) {
1818 break;
1819 }
1820 $budget -= strlen( $json );
1821 $entries[] = $json;
1822 }
1823
1824 return sprintf(
1825 "The user asked: %s\n\nYour searches are done and no tools are available now. These are the results they returned:\n%s\n\nAnswer the user's request from these results, using the answer format. If one item fits, return it as \"entity\"; if several fit, return up to 3 as \"navigation\" links built from their edit_url. Only say you found nothing if none of these results fit.",
1826 $query,
1827 implode( "\n", array_reverse( $entries ) )
1828 );
1829 }
1830
1831 /**
1832 * Compose the final system-prompt string for an `/ai/search` call.
1833 *
1834 * One code path used by both the primary run and the follow-up leg —
1835 * keeps the voice consistent across legs and removes a class of drift
1836 * bug where the two paths's prompt-assembly drifts apart.
1837 *
1838 * Applies three layers in order:
1839 * 1. `openstation_ai_system_prompt_appendix` — stacking filter;
1840 * every plugin's return is concatenated.
1841 * 2. Client override — `system_prompt_text` + `system_prompt_mode`.
1842 * `append` always allowed; `replace` gated on
1843 * `openstation_ai_system_prompt_replace_capability`. Non-permitted
1844 * `replace` downgrades to `append` so the caller's text is
1845 * preserved rather than dropped.
1846 * 3. `openstation_ai_system_prompt` — final transform pass.
1847 *
1848 * @internal
1849 *
1850 * @param string $core Built-in instructions for this phase
1851 * (agent loop / follow-up summariser).
1852 * @param array $context { query, user_id, request_id, phase? }.
1853 * @param array $client { text, mode } client override; either field
1854 * empty means no override.
1855 * @return string Composed system prompt.
1856 */
1857 function openstation_ai_compose_instructions( $core, array $context, array $client = array() ) {
1858 $instructions = (string) $core;
1859 $user_id = isset( $context['user_id'] ) ? (int) $context['user_id'] : 0;
1860
1861 $client_text = isset( $client['text'] ) && is_string( $client['text'] ) ? $client['text'] : '';
1862 $client_mode = isset( $client['mode'] ) && in_array( $client['mode'], array( 'append', 'replace' ), true )
1863 ? (string) $client['mode']
1864 : 'append';
1865
1866 $ctx_for_filter = $context;
1867 $ctx_for_filter['client_override'] = '' !== $client_text ? $client_mode : null;
1868
1869 /**
1870 * Short-circuit extension — appended to the built-in instructions
1871 * verbatim. Use this when a plugin just wants to add domain
1872 * context (room list, product catalogue, company jargon) without
1873 * restructuring the core rules. Fires for both the primary
1874 * `/ai/search` run and the follow-up composed-reply leg.
1875 *
1876 * @param string $appendix Accumulated appendix. Default empty.
1877 * @param array $context { query, user_id, request_id, client_override, phase? }.
1878 */
1879 $server_appendix = (string) apply_filters( 'openstation_ai_system_prompt_appendix', '', $ctx_for_filter );
1880 if ( '' !== $server_appendix ) {
1881 $instructions .= "\n\n" . $server_appendix;
1882 }
1883
1884 if ( '' !== $client_text ) {
1885 if ( 'replace' === $client_mode ) {
1886 /**
1887 * Capability required for a client to send
1888 * `system_prompt: { mode: 'replace' }`. Defaults to `manage_options`
1889 * — replacing the whole prompt can effectively hijack the
1890 * assistant, so it's admin-only out of the box.
1891 *
1892 * @param string $capability Default `manage_options`.
1893 * @param array $context
1894 */
1895 $required_cap = (string) apply_filters(
1896 'openstation_ai_system_prompt_replace_capability',
1897 'manage_options',
1898 $ctx_for_filter
1899 );
1900 if ( '' === $required_cap || ( $user_id > 0 && user_can( $user_id, $required_cap ) ) ) {
1901 $instructions = $client_text;
1902 } else {
1903 // Silently downgrade to append — preserves the caller's
1904 // text rather than dropping it when the cap check fails.
1905 $instructions .= "\n\n" . $client_text;
1906 }
1907 } else {
1908 $instructions .= "\n\n" . $client_text;
1909 }
1910 }
1911
1912 /**
1913 * Final transform pass. Fires after the built-in instructions,
1914 * server appendix, and client override have all been composed.
1915 *
1916 * @param string $instructions Composed system prompt.
1917 * @param array $context
1918 */
1919 return (string) apply_filters( 'openstation_ai_system_prompt', $instructions, $ctx_for_filter );
1920 }
1921
1922 /**
1923 * Compose a natural-language reply describing the outcome of a
1924 * client-dispatched command invocation.
1925 *
1926 * Called by the REST endpoint when the client sends `follow_up` —
1927 * the second leg of the opt-in agentic flow triggered by
1928 * `wp.os.ai.ask( q, { tools: 'aiCallable', followUp: true } )`.
1929 *
1930 * Single-turn, no tools, no structured-output schema — the model
1931 * sees the original query + a summary of what happened and writes a
1932 * one/two-sentence reply in the voice of the system prompt. We reuse
1933 * the same system-prompt pipeline as the main search so plugins
1934 * appending instructions via `openstation_ai_system_prompt_appendix`
1935 * see consistent voice across the two legs.
1936 *
1937 * @param string $query Original user query.
1938 * @param array $tool { slug, args } — what ran.
1939 * @param array $outcome Tool result payload. Opaque — JSON-encoded
1940 * into the model's context so it can reason
1941 * about whatever shape the plugin returned.
1942 * @param array $extra Same shape as `openstation_ai_run_search`'s
1943 * `$extra` — carries user_id, request_id,
1944 * system-prompt overrides.
1945 * @return array|WP_Error `{ answer_type: 'chat', message, … }` or error.
1946 */
1947 function openstation_ai_run_followup( $query, array $tool, array $outcome, array $extra = array() ) {
1948 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
1949 $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && '' !== $extra['request_id']
1950 ? (string) $extra['request_id']
1951 : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ) );
1952
1953 do_action(
1954 'openstation_ai_search_started',
1955 array(
1956 'query' => $query,
1957 'user_id' => $user_id,
1958 'request_id' => $request_id,
1959 'phase' => 'follow_up',
1960 )
1961 );
1962
1963 // Mirror the main search's system-prompt layering so voice stays
1964 // consistent between the two legs. We build a simpler core-
1965 // instructions block — no tool guidance, since this run has none.
1966 $instructions = '
1967 You are the same friendly WordPress assistant that just dispatched a command on behalf of the user. You now have the result of that command.
1968
1969 Write a short reply (one or two sentences, first person, warm and conversational) describing what happened. Match the voice the site owner set in their system prompt — do not restart small talk, just confirm what you did.
1970
1971 Rules:
1972 - If the outcome looks successful, confirm plainly. Example: "Done — your office light is on now."
1973 - If the outcome looks like an error (has an `error` field, a failure message, or obviously negative content), apologise briefly and paraphrase what went wrong. Do not invent details the outcome did not include.
1974 - Suggest a next step only when the outcome itself suggests one.
1975 - Describe the real-world effect, not the tool mechanism ("I called command_turn_light").
1976 ';
1977
1978 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
1979 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1980 ? (string) $extra['system_prompt_mode']
1981 : 'append';
1982
1983 $instructions = openstation_ai_compose_instructions(
1984 $instructions,
1985 array(
1986 'query' => $query,
1987 'user_id' => $user_id,
1988 'request_id' => $request_id,
1989 'phase' => 'follow_up',
1990 ),
1991 array(
1992 'text' => $system_prompt_text,
1993 'mode' => $system_prompt_mode,
1994 )
1995 );
1996
1997 $slug = isset( $tool['slug'] ) ? (string) $tool['slug'] : '';
1998 $tool_args = isset( $tool['args'] ) ? (string) $tool['args'] : '';
1999 $outcome_json = wp_json_encode( $outcome );
2000 if ( ! is_string( $outcome_json ) ) {
2001 $outcome_json = '""';
2002 }
2003
2004 // Bound the outcome payload so a malicious or buggy plugin that
2005 // returns a 5MB blob can't inflate the provider token usage without
2006 // bound. 4 KB is enough for a status string, a small result list,
2007 // or a short error envelope — anything bigger gets truncated with
2008 // a marker so the model knows the tail was dropped.
2009 //
2010 // `mb_*` variants so truncation on a multibyte boundary
2011 // (Japanese / emoji / accented UTF-8) can't produce invalid JSON
2012 // that the provider would reject. Falls back to byte-level substr when
2013 // mbstring is unavailable (rare but possible on minimal PHP
2014 // builds).
2015 $max_outcome_len = (int) apply_filters( 'openstation_ai_followup_outcome_max_chars', 4000 );
2016 if ( $max_outcome_len > 0 ) {
2017 $has_mbstring = function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' );
2018 $current_len = $has_mbstring
2019 ? mb_strlen( $outcome_json, 'UTF-8' )
2020 : strlen( $outcome_json );
2021 if ( $current_len > $max_outcome_len ) {
2022 $outcome_json = $has_mbstring
2023 ? mb_substr( $outcome_json, 0, $max_outcome_len, 'UTF-8' )
2024 : substr( $outcome_json, 0, $max_outcome_len );
2025 $outcome_json .= '…[truncated]';
2026 }
2027 }
2028
2029 $user_message = sprintf(
2030 "Original user request: %s\n\nYou invoked the command `%s` with args `%s`. It returned:\n\n```json\n%s\n```\n\nWrite your short confirmation / apology now.",
2031 $query,
2032 $slug,
2033 $tool_args,
2034 $outcome_json
2035 );
2036
2037 do_action(
2038 'openstation_ai_tool_called',
2039 array(
2040 'tool_name' => 'followup_summarise',
2041 'args' => array(
2042 'slug' => $slug,
2043 'tool_args' => $tool_args,
2044 ),
2045 'user_id' => $user_id,
2046 'request_id' => $request_id,
2047 )
2048 );
2049
2050 $turn = openstation_ai_client_generate(
2051 $user_id,
2052 array( openstation_ai_user_text_message( $user_message ) ),
2053 array(), // no tools — we want a plain reply
2054 null, // no JSON schema — free-form text
2055 $instructions,
2056 array(
2057 'source' => 'ai-copilot/followup',
2058 'request_id' => $request_id,
2059 )
2060 );
2061
2062 // `openstation_ai_empty_answer` is the one generation error this path
2063 // deliberately absorbs: the command DID run, so a text-less summary turn
2064 // degrades to the generic confirmation below instead of surfacing as a
2065 // failure of the command itself.
2066 $empty_answer = is_wp_error( $turn ) && 'openstation_ai_empty_answer' === $turn->get_error_code();
2067
2068 if ( is_wp_error( $turn ) && ! $empty_answer ) {
2069 do_action(
2070 'openstation_ai_search_error',
2071 array(
2072 'code' => $turn->get_error_code(),
2073 'message' => $turn->get_error_message(),
2074 'data' => $turn->get_error_data(),
2075 'user_id' => $user_id,
2076 'request_id' => $request_id,
2077 'phase' => 'follow_up',
2078 )
2079 );
2080 return $turn;
2081 }
2082
2083 $text = $empty_answer ? null : ( $turn['text'] ?? null );
2084 $fallback = false;
2085 if ( ! is_string( $text ) || '' === trim( $text ) ) {
2086 // Graceful degrade — if the provider returned nothing usable, fall
2087 // back to a generic confirmation so the caller always has a
2088 // message to show. Better than returning an error and losing
2089 // the fact that the command *did* run. We flag the degrade so
2090 // observability subscribers can distinguish a deliberate
2091 // "Done." from a silently-degraded one.
2092 $text = 'Done.';
2093 $fallback = true;
2094 }
2095
2096 $final = array(
2097 'answer_type' => 'chat',
2098 'message' => trim( $text ),
2099 'entity' => null,
2100 'admin_links' => null,
2101 'iterations' => 1,
2102 'exhausted' => false,
2103 'continue' => null,
2104 'request_id' => $request_id,
2105 'tool' => array(
2106 'slug' => $slug,
2107 'args' => $tool_args,
2108 ),
2109 'fallback' => $fallback,
2110 );
2111
2112 $final = (array) apply_filters(
2113 'openstation_ai_answer',
2114 $final,
2115 array(
2116 'query' => $query,
2117 'user_id' => $user_id,
2118 'request_id' => $request_id,
2119 'phase' => 'follow_up',
2120 )
2121 );
2122
2123 do_action(
2124 'openstation_ai_search_completed',
2125 array(
2126 'query' => $query,
2127 'user_id' => $user_id,
2128 'request_id' => $request_id,
2129 'answer_type' => 'chat',
2130 'iterations' => 1,
2131 'phase' => 'follow_up',
2132 'fallback' => $fallback,
2133 )
2134 );
2135
2136 return $final;
2137 }
2138
2139 // ---------------------------------------------------------------------------
2140 // REST endpoint
2141 // ---------------------------------------------------------------------------
2142
2143 /**
2144 * Registers the AI search REST route.
2145 */
2146 function openstation_register_ai_search_rest_route() {
2147 register_rest_route(
2148 'desktop-mode/v1',
2149 '/ai/search',
2150 array(
2151 'methods' => WP_REST_Server::CREATABLE,
2152 'callback' => 'openstation_rest_ai_search',
2153 'permission_callback' => 'openstation_rest_ai_search_permission',
2154 'args' => array(
2155 'query' => array(
2156 'required' => true,
2157 'type' => 'string',
2158 'sanitize_callback' => 'sanitize_text_field',
2159 'validate_callback' => static function ( $v ) {
2160 return is_string( $v ) && trim( $v ) !== '';
2161 },
2162 ),
2163 // `resume_tool` + `start_offset` are only set when the
2164 // client is continuing a previous search from the `continue`
2165 // object returned by an exhausted run. Fresh searches leave
2166 // both unset — the agent picks tools from query semantics.
2167 'resume_tool' => array(
2168 'required' => false,
2169 'type' => array( 'string', 'null' ),
2170 'default' => null,
2171 'sanitize_callback' => static function ( $v ) {
2172 return in_array( $v, openstation_ai_search_resumable_tools(), true )
2173 ? $v : null;
2174 },
2175 ),
2176 'start_offset' => array(
2177 'required' => false,
2178 'type' => 'integer',
2179 'default' => 0,
2180 'sanitize_callback' => 'absint',
2181 ),
2182 // Client-harvested slash-commands the user's plugins have
2183 // opted in as AI tools. Each entry: { slug, label, description?, hint? }.
2184 // The slug is namespaced server-side as `command_<slug>`
2185 // and any tool_call the model emits with that name short-
2186 // circuits back to the client for local dispatch.
2187 'command_tools' => array(
2188 'required' => false,
2189 'type' => 'array',
2190 'default' => array(),
2191 'items' => array(
2192 'type' => 'object',
2193 'properties' => array(
2194 'slug' => array( 'type' => 'string' ),
2195 'label' => array( 'type' => 'string' ),
2196 'description' => array( 'type' => 'string' ),
2197 'hint' => array( 'type' => 'string' ),
2198 ),
2199 ),
2200 ),
2201 // Free-form system-prompt override.
2202 // mode: 'append' → concatenated onto the built-in prompt (safe for everyone)
2203 // mode: 'replace' → replaces the built-in prompt entirely, gated on
2204 // `openstation_ai_system_prompt_replace_capability`
2205 // (default `manage_options`).
2206 'system_prompt_text' => array(
2207 'required' => false,
2208 'type' => 'string',
2209 'default' => '',
2210 ),
2211 'system_prompt_mode' => array(
2212 'required' => false,
2213 'type' => 'string',
2214 'default' => 'append',
2215 'sanitize_callback' => static function ( $v ) {
2216 return in_array( $v, array( 'append', 'replace' ), true ) ? $v : 'append';
2217 },
2218 ),
2219 // Follow-up leg of the agentic command-dispatch flow.
2220 // When present, the endpoint SKIPS the agent loop entirely
2221 // and runs a single-turn "summarise this outcome" call
2222 // through the provider instead. The client sends this on the
2223 // second leg of `ask( q, { tools: 'aiCallable', followUp: true } )`.
2224 'follow_up' => array(
2225 'required' => false,
2226 'type' => array( 'object', 'null' ),
2227 'default' => null,
2228 ),
2229 ),
2230 )
2231 );
2232 }
2233 add_action( 'rest_api_init', 'openstation_register_ai_search_rest_route' );
2234
2235 /**
2236 * Permission callback.
2237 *
2238 * @return bool|WP_Error
2239 */
2240 function openstation_rest_ai_search_permission() {
2241 if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
2242 return new WP_Error(
2243 'openstation_ai_forbidden',
2244 __( 'You must be logged in to use the AI assistant.', 'desktop-mode' ),
2245 array( 'status' => 403 )
2246 );
2247 }
2248 if ( ! openstation_ai_is_available() ) {
2249 return new WP_Error(
2250 'openstation_ai_unavailable',
2251 __( 'The AI assistant is unavailable on this site.', 'desktop-mode' ),
2252 array( 'status' => 503 )
2253 );
2254 }
2255 if ( ! openstation_ai_is_enabled( get_current_user_id() ) ) {
2256 // The message names the tab for consumers that only get text (a REST
2257 // client, `wp.os.ai.ask()`). `settings_tab` names it again as data,
2258 // so the overlay can offer a one-click link without parsing prose.
2259 return new WP_Error(
2260 'openstation_ai_disabled',
2261 __(
2262 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.',
2263 'desktop-mode'
2264 ),
2265 array(
2266 'status' => 403,
2267 'settings_tab' => 'features',
2268 )
2269 );
2270 }
2271 return true;
2272 }
2273
2274 /**
2275 * POST /desktop-mode/v1/ai/search
2276 *
2277 * @param WP_REST_Request $request
2278 * @return WP_REST_Response|WP_Error
2279 */
2280 function openstation_rest_ai_search( WP_REST_Request $request ) {
2281 // The agent loop runs up to OPENSTATION_AI_SEARCH_MAX_ITERATIONS model
2282 // round-trips, each with a tool call, which overruns a default 30s
2283 // max_execution_time. The streaming endpoint used to raise this; it is
2284 // the only path now, so it carries the limit.
2285 @set_time_limit( 120 ); // phpcs:ignore
2286
2287 $user_id = get_current_user_id();
2288 $query = $request->get_param( 'query' );
2289 $resume_tool = $request->get_param( 'resume_tool' );
2290 $start_offset = $request->get_param( 'start_offset' );
2291
2292 $command_tools = $request->get_param( 'command_tools' );
2293 if ( ! is_array( $command_tools ) ) {
2294 $command_tools = array();
2295 }
2296
2297 $extra = array(
2298 'user_id' => $user_id,
2299 'request_id' => function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ),
2300 'command_tools' => $command_tools,
2301 'system_prompt_text' => (string) $request->get_param( 'system_prompt_text' ),
2302 'system_prompt_mode' => (string) $request->get_param( 'system_prompt_mode' ),
2303 );
2304
2305 /**
2306 * Last-mile filter on the whole `/ai/search` request bundle.
2307 * Plugins get one hook to rewrite query, swap tools, or inject
2308 * metadata before the agent loop starts.
2309 *
2310 * @param array $extra Extended context (mutable).
2311 * @param array $core Core request params { query, resume_tool, start_offset }.
2312 */
2313 $extra = (array) apply_filters(
2314 'openstation_ai_request',
2315 $extra,
2316 array(
2317 'query' => $query,
2318 'resume_tool' => $resume_tool,
2319 'start_offset' => $start_offset,
2320 )
2321 );
2322
2323 // Follow-up leg — skip the agent loop, summarise the tool outcome.
2324 $follow_up = $request->get_param( 'follow_up' );
2325 if ( is_array( $follow_up ) && isset( $follow_up['tool'] ) && is_array( $follow_up['tool'] ) ) {
2326 $tool = $follow_up['tool'];
2327 $outcome = isset( $follow_up['result'] )
2328 ? ( is_array( $follow_up['result'] ) ? $follow_up['result'] : array( 'value' => $follow_up['result'] ) )
2329 : array();
2330 $result = openstation_ai_run_followup( $query, $tool, $outcome, $extra );
2331 } else {
2332 $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, $extra );
2333 }
2334
2335 if ( is_wp_error( $result ) ) {
2336 $request_id = isset( $extra['request_id'] ) ? (string) $extra['request_id'] : '';
2337 do_action(
2338 'openstation_ai_search_error',
2339 array(
2340 'code' => $result->get_error_code(),
2341 'message' => $result->get_error_message(),
2342 'data' => $result->get_error_data(),
2343 'user_id' => $user_id,
2344 'request_id' => $request_id,
2345 )
2346 );
2347 return $result;
2348 }
2349
2350 return rest_ensure_response( $result );
2351 }
2352
2353 // ---------------------------------------------------------------------------
2354 // Tool: search_wporg_plugins — uses core's plugins_api() which queries
2355 // the official WordPress.org repository. Results are cached in a
2356 // 10-minute transient per query so repeated asks don't hammer w.org.
2357 // ---------------------------------------------------------------------------
2358
2359 /**
2360 * Search the WordPress.org plugin directory.
2361 *
2362 * @param string $query Search terms.
2363 * @return array Tool result payload ready for the model.
2364 */
2365 function openstation_ai_fetch_wporg_plugins( $query ) {
2366 $query = trim( (string) $query );
2367 if ( '' === $query ) {
2368 return array(
2369 'tool' => 'search_wporg_plugins',
2370 'query' => '',
2371 'results' => array(),
2372 'count' => 0,
2373 'error' => 'No search query provided.',
2374 );
2375 }
2376
2377 // Transient cache to protect the w.org API from repeated queries
2378 // within the same conversation.
2379 $cache_key = 'openstation_ai_plugins_' . md5( strtolower( $query ) );
2380 $cached = get_transient( $cache_key );
2381 if ( is_array( $cached ) ) {
2382 return $cached;
2383 }
2384
2385 if ( ! function_exists( 'plugins_api' ) ) {
2386 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
2387 }
2388
2389 $api = plugins_api(
2390 'query_plugins',
2391 array(
2392 'search' => $query,
2393 'per_page' => 10,
2394 'fields' => array(
2395 'short_description' => true,
2396 'description' => false,
2397 'sections' => false,
2398 'requires' => true,
2399 'tested' => true,
2400 'rating' => true,
2401 'ratings' => false,
2402 'downloaded' => false,
2403 'downloadlink' => false,
2404 'last_updated' => true,
2405 'added' => false,
2406 'tags' => false,
2407 'compatibility' => false,
2408 'homepage' => true,
2409 'versions' => false,
2410 'donate_link' => false,
2411 'reviews' => false,
2412 'banners' => false,
2413 'icons' => true,
2414 'active_installs' => true,
2415 'group' => false,
2416 'contributors' => false,
2417 ),
2418 )
2419 );
2420
2421 if ( is_wp_error( $api ) ) {
2422 return array(
2423 'tool' => 'search_wporg_plugins',
2424 'query' => $query,
2425 'results' => array(),
2426 'count' => 0,
2427 'error' => $api->get_error_message(),
2428 );
2429 }
2430
2431 $results = array();
2432 $plugins = isset( $api->plugins ) && is_array( $api->plugins ) ? $api->plugins : array();
2433 foreach ( $plugins as $p ) {
2434 // Normalise — plugins_api sometimes returns arrays, sometimes objects.
2435 $p = (array) $p;
2436
2437 $slug = isset( $p['slug'] ) ? (string) $p['slug'] : '';
2438 if ( '' === $slug ) {
2439 continue;
2440 }
2441
2442 $icon = '';
2443 $icons = isset( $p['icons'] ) && is_array( $p['icons'] ) ? $p['icons'] : array();
2444 if ( isset( $icons['1x'] ) ) {
2445 $icon = (string) $icons['1x'];
2446 } elseif ( isset( $icons['default'] ) ) {
2447 $icon = (string) $icons['default'];
2448 } elseif ( isset( $icons['svg'] ) ) {
2449 $icon = (string) $icons['svg'];
2450 }
2451
2452 // Admin URL that opens the plugin-information thickbox. Includes
2453 // both &plugin=slug and the TB_iframe params so clicking it in
2454 // wp-admin behaves like a native "More details" link.
2455 $install_admin_url = admin_url(
2456 'plugin-install.php?tab=plugin-information&plugin=' . rawurlencode( $slug )
2457 . '&TB_iframe=true&width=772&height=745'
2458 );
2459
2460 $results[] = array(
2461 'name' => wp_strip_all_tags( $p['name'] ?? '' ),
2462 'slug' => $slug,
2463 'short_description' => wp_strip_all_tags( $p['short_description'] ?? '' ),
2464 'version' => (string) ( $p['version'] ?? '' ),
2465 'author' => wp_strip_all_tags( $p['author'] ?? '' ),
2466 'rating' => (int) ( $p['rating'] ?? 0 ), // 0-100
2467 'stars' => round( ( (int) ( $p['rating'] ?? 0 ) ) / 20, 1 ), // 0-5, as wordpress.org shows it
2468 'num_ratings' => (int) ( $p['num_ratings'] ?? 0 ),
2469 'active_installs' => (int) ( $p['active_installs'] ?? 0 ),
2470 'last_updated' => (string) ( $p['last_updated'] ?? '' ),
2471 'requires' => (string) ( $p['requires'] ?? '' ),
2472 'tested' => (string) ( $p['tested'] ?? '' ),
2473 'homepage' => esc_url_raw( $p['homepage'] ?? '' ),
2474 'wporg_url' => 'https://wordpress.org/plugins/' . $slug . '/',
2475 'install_admin_url' => $install_admin_url,
2476 'icon' => esc_url_raw( $icon ),
2477 );
2478 }
2479
2480 $payload = array(
2481 'tool' => 'search_wporg_plugins',
2482 'query' => $query,
2483 'results' => $results,
2484 'count' => count( $results ),
2485 );
2486
2487 set_transient( $cache_key, $payload, 10 * MINUTE_IN_SECONDS );
2488
2489 return $payload;
2490 }
2491
2492 // ---------------------------------------------------------------------------
2493 // Tool: get_php_error_log — reads the tail of the site's error log.
2494 // Admin-only; the capability check is performed in the dispatcher
2495 // BEFORE this function runs, so by the time we get here the caller is
2496 // known to hold manage_options.
2497 // ---------------------------------------------------------------------------
2498
2499 /**
2500 * Read and parse the tail of the site's PHP error log.
2501 *
2502 * Tries WP_CONTENT_DIR/debug.log first (populated by WP_DEBUG_LOG), then
2503 * falls back to the PHP ini `error_log` directive. If neither points at
2504 * a readable file the tool reports log_available=false rather than
2505 * throwing.
2506 *
2507 * @param int $lines Number of lines to return (clamped 1-500 by caller).
2508 * @return array
2509 */
2510 function openstation_ai_fetch_error_log( $lines = 50 ) {
2511 $candidates = array();
2512 if ( defined( 'WP_CONTENT_DIR' ) ) {
2513 $candidates[] = WP_CONTENT_DIR . '/debug.log';
2514 }
2515 $ini_log = (string) ini_get( 'error_log' );
2516 if ( '' !== $ini_log && 'syslog' !== $ini_log ) {
2517 $candidates[] = $ini_log;
2518 }
2519
2520 /**
2521 * Filter the list of log-file paths to probe in order. Plugins that
2522 * redirect errors somewhere non-standard can add their path here.
2523 *
2524 * @param string[] $candidates File paths, in probe order.
2525 */
2526 $candidates = (array) apply_filters( 'openstation_ai_error_log_candidates', $candidates );
2527
2528 $log_path = '';
2529 foreach ( $candidates as $path ) {
2530 if ( is_string( $path ) && is_file( $path ) && is_readable( $path ) ) {
2531 $log_path = $path;
2532 break;
2533 }
2534 }
2535
2536 if ( '' === $log_path ) {
2537 return array(
2538 'tool' => 'get_php_error_log',
2539 'log_available' => false,
2540 'message' => 'No readable error log found. Enable WP_DEBUG_LOG in wp-config.php or set php_value error_log.',
2541 'checked_paths' => array_values( $candidates ),
2542 'entries' => array(),
2543 'count' => 0,
2544 );
2545 }
2546
2547 $tail = openstation_ai_tail_file( $log_path, $lines );
2548
2549 $entries = array();
2550 foreach ( $tail as $line ) {
2551 $line = trim( $line );
2552 if ( '' === $line ) {
2553 continue;
2554 }
2555 $entries[] = openstation_ai_parse_log_line( $line );
2556 }
2557
2558 return array(
2559 'tool' => 'get_php_error_log',
2560 'log_available' => true,
2561 'source' => $log_path,
2562 'entries' => $entries,
2563 'count' => count( $entries ),
2564 );
2565 }
2566
2567 /**
2568 * Parse a PHP error_log line into { timestamp, level, message }.
2569 *
2570 * PHP's default format is `[<date>] <prefix>: <message>` where the
2571 * prefix is usually "PHP Fatal error", "PHP Warning", etc. Falls back
2572 * to a raw line when the format doesn't match.
2573 *
2574 * @param string $line
2575 * @return array
2576 */
2577 function openstation_ai_parse_log_line( $line ) {
2578 // Cap individual messages so a runaway stack trace doesn't balloon
2579 // the payload sent to the provider.
2580 $line = mb_substr( $line, 0, 600 );
2581
2582 $entry = array(
2583 'timestamp' => '',
2584 'level' => 'Log',
2585 'message' => $line,
2586 );
2587
2588 // [21-Apr-2026 10:30:22 UTC] PHP Fatal error: Uncaught Error: …
2589 if ( preg_match( '/^\[([^\]]+)\]\s*(.*)$/', $line, $m ) ) {
2590 $entry['timestamp'] = $m[1];
2591 $entry['message'] = $m[2];
2592 }
2593
2594 if ( preg_match( '/^(PHP (?:Fatal error|Parse error|Warning|Notice|Deprecated|Strict|Recoverable fatal error))/i', $entry['message'], $lm ) ) {
2595 $entry['level'] = $lm[1];
2596 $entry['message'] = trim( substr( $entry['message'], strlen( $lm[1] ) ), ":\t " );
2597 }
2598
2599 return $entry;
2600 }
2601
2602 /**
2603 * Return the last $lines of a file. Loads the file via WP_Filesystem
2604 * (the only file-read API allowed for wp.org-hosted plugins) and
2605 * slices off the trailing N+1 entries. Realistic error logs sit
2606 * in the kilobyte range when admins look at them; if a site routinely
2607 * lets logs grow into tens of MB, that's the symptom, not this read.
2608 *
2609 * @param string $path Absolute path to the file.
2610 * @param int $lines
2611 * @return string[] Lines in original order (oldest first).
2612 */
2613 function openstation_ai_tail_file( $path, $lines ) {
2614 if ( ! function_exists( 'WP_Filesystem' ) ) {
2615 require_once ABSPATH . 'wp-admin/includes/file.php';
2616 }
2617 WP_Filesystem();
2618 global $wp_filesystem;
2619 if ( ! $wp_filesystem || ! $wp_filesystem->exists( $path ) ) {
2620 return array();
2621 }
2622
2623 $contents = $wp_filesystem->get_contents( $path );
2624 if ( false === $contents || '' === $contents ) {
2625 return array();
2626 }
2627
2628 $all = preg_split( '/\r?\n/', $contents );
2629 if ( ! is_array( $all ) ) {
2630 return array();
2631 }
2632
2633 return array_slice( $all, -1 * ( $lines + 1 ) );
2634 }
2635