PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.1
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / ai-copilot / search.php

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

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