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

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

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