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

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

2,476 lines 92.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — AI Copilot content search via the provider tool use.
4 *
5 * Agentic search loop: the user describes something in natural language and
6 * the agent calls focused tools, choosing the right one based on query
7 * semantics. Built-in tools: four content-search tools — search_posts,
8 * search_pages, search_comments, search_comments_by_post — plus
9 * list_admin_pages (admin navigation catalog), search_wporg_plugins
10 * (WordPress.org plugin directory), and get_php_error_log (error-log
11 * tail). Each content-search tool runs WordPress's native search
12 * (WP_Query `s=` / get_comments `search=`) for the keywords the model
13 * distils from the request, then returns up to 10 matching entities with
14 * their real title + content excerpt for the model to compare to the
15 * user's description. No AI pre-analysis is required — every published
16 * post/page/comment is findable. The built-in tools are WordPress Abilities
17 * (see abilities.php); client command tools are advertised alongside them and
18 * dispatched by the same loop.
19 *
20 * Focused tools instead of one routing parameter:
21 * - "I remember a comment where someone said congratulations…" → agent
22 * calls search_comments without needing a routing parameter.
23 * - "I wrote a post about paella in Canarias" → agent calls search_posts.
24 * - "Our About page mentions…" → agent calls search_pages.
25 * - Ambiguous queries → agent tries in priority order (posts → pages →
26 * comments) following the system-prompt guidance.
27 *
28 * Budget: max OPENSTATION_AI_SEARCH_MAX_ITERATIONS (10) tool-call rounds per
29 * request × OPENSTATION_AI_SEARCH_BATCH_SIZE (10) items = up to 100 entities.
30 * When the budget is exhausted the response includes a `continue` object
31 * the client uses to resume from the exact offset that was last searched.
32 *
33 * REST endpoint: POST /desktop-mode/v1/ai/search
34 *
35 * @package OpenStation
36 */
37
38 defined( 'ABSPATH' ) || exit;
39
40 /** Maximum agentic tool-call iterations per search request. */
41 const OPENSTATION_AI_SEARCH_MAX_ITERATIONS = 10;
42
43 /** Entities fetched per tool-call round. */
44 const OPENSTATION_AI_SEARCH_BATCH_SIZE = 10;
45
46 /**
47 * Returns the catalog of common WordPress admin destinations.
48 *
49 * Used by the `list_admin_pages` tool. Each entry has a human title, the
50 * wp-admin URL (rendered through admin_url() so it respects the site's
51 * real admin path), a short description, and a Dashicons icon class the
52 * UI can use when opening the URL in a legacy iframe window.
53 *
54 * Filterable via `openstation_ai_admin_page_catalog` so third-party
55 * plugins can contribute their own admin destinations (e.g. a plugin
56 * adding a top-level menu can surface its settings page here).
57 *
58 * @return array[]
59 */
60 function openstation_ai_get_admin_page_catalog() {
61 $catalog = array(
62 array(
63 'title' => __( 'Dashboard', 'desktop-mode' ),
64 'url' => admin_url( 'index.php' ),
65 'icon' => 'dashicons-dashboard',
66 'description' => __( 'The main admin dashboard: activity, drafts, site overview.', 'desktop-mode' ),
67 ),
68 array(
69 'title' => __( 'All Posts', 'desktop-mode' ),
70 'url' => admin_url( 'edit.php' ),
71 'icon' => 'dashicons-admin-post',
72 'description' => __( 'List, edit, bulk-manage blog posts.', 'desktop-mode' ),
73 ),
74 array(
75 'title' => __( 'Add New Post', 'desktop-mode' ),
76 'url' => admin_url( 'post-new.php' ),
77 'icon' => 'dashicons-plus',
78 'description' => __( 'Create a new blog post.', 'desktop-mode' ),
79 ),
80 array(
81 'title' => __( 'Categories', 'desktop-mode' ),
82 'url' => admin_url( 'edit-tags.php?taxonomy=category' ),
83 'icon' => 'dashicons-category',
84 'description' => __( 'Manage post categories: add, rename, merge.', 'desktop-mode' ),
85 ),
86 array(
87 'title' => __( 'Tags', 'desktop-mode' ),
88 'url' => admin_url( 'edit-tags.php?taxonomy=post_tag' ),
89 'icon' => 'dashicons-tag',
90 'description' => __( 'Manage post tags.', 'desktop-mode' ),
91 ),
92 array(
93 'title' => __( 'All Pages', 'desktop-mode' ),
94 'url' => admin_url( 'edit.php?post_type=page' ),
95 'icon' => 'dashicons-admin-page',
96 'description' => __( 'List and edit static pages (About, Contact, etc.).', 'desktop-mode' ),
97 ),
98 array(
99 'title' => __( 'Add New Page', 'desktop-mode' ),
100 'url' => admin_url( 'post-new.php?post_type=page' ),
101 'icon' => 'dashicons-plus',
102 'description' => __( 'Create a new page.', 'desktop-mode' ),
103 ),
104 array(
105 'title' => __( 'Media Library', 'desktop-mode' ),
106 'url' => admin_url( 'upload.php' ),
107 'icon' => 'dashicons-admin-media',
108 'description' => __( 'Browse, upload, and manage images, files, videos.', 'desktop-mode' ),
109 ),
110 array(
111 'title' => __( 'Comments', 'desktop-mode' ),
112 'url' => admin_url( 'edit-comments.php' ),
113 'icon' => 'dashicons-admin-comments',
114 'description' => __( 'Moderate and reply to comments on posts and pages.', 'desktop-mode' ),
115 ),
116 array(
117 'title' => __( 'Themes', 'desktop-mode' ),
118 'url' => admin_url( 'themes.php' ),
119 'icon' => 'dashicons-admin-appearance',
120 'description' => __( 'Change, install, or customize the active theme.', 'desktop-mode' ),
121 ),
122 array(
123 'title' => __( 'Customize', 'desktop-mode' ),
124 'url' => admin_url( 'customize.php' ),
125 'icon' => 'dashicons-admin-customizer',
126 'description' => __( 'Live-preview theme customisation: colors, fonts, layout.', 'desktop-mode' ),
127 ),
128 array(
129 'title' => __( 'Widgets', 'desktop-mode' ),
130 'url' => admin_url( 'widgets.php' ),
131 'icon' => 'dashicons-screenoptions',
132 'description' => __( 'Manage sidebar and footer widgets.', 'desktop-mode' ),
133 ),
134 array(
135 'title' => __( 'Menus', 'desktop-mode' ),
136 'url' => admin_url( 'nav-menus.php' ),
137 'icon' => 'dashicons-menu',
138 'description' => __( 'Create and edit navigation menus.', 'desktop-mode' ),
139 ),
140 array(
141 'title' => __( 'Plugins', 'desktop-mode' ),
142 'url' => admin_url( 'plugins.php' ),
143 'icon' => 'dashicons-admin-plugins',
144 'description' => __( 'Activate, deactivate, update or delete plugins.', 'desktop-mode' ),
145 ),
146 array(
147 'title' => __( 'Add New Plugin', 'desktop-mode' ),
148 'url' => admin_url( 'plugin-install.php' ),
149 'icon' => 'dashicons-plus',
150 'description' => __( 'Search and install new plugins from the directory.', 'desktop-mode' ),
151 ),
152 array(
153 'title' => __( 'Users', 'desktop-mode' ),
154 'url' => admin_url( 'users.php' ),
155 'icon' => 'dashicons-admin-users',
156 'description' => __( 'Manage user accounts and roles.', 'desktop-mode' ),
157 ),
158 array(
159 'title' => __( 'Add New User', 'desktop-mode' ),
160 'url' => admin_url( 'user-new.php' ),
161 'icon' => 'dashicons-plus',
162 'description' => __( 'Create a new user account.', 'desktop-mode' ),
163 ),
164 array(
165 'title' => __( 'Your Profile', 'desktop-mode' ),
166 'url' => admin_url( 'profile.php' ),
167 'icon' => 'dashicons-id',
168 'description' => __( 'Edit your own profile, password, admin colour scheme.', 'desktop-mode' ),
169 ),
170 array(
171 'title' => __( 'General Settings', 'desktop-mode' ),
172 'url' => admin_url( 'options-general.php' ),
173 'icon' => 'dashicons-admin-settings',
174 'description' => __( 'Site title, tagline, URL, timezone, language.', 'desktop-mode' ),
175 ),
176 array(
177 'title' => __( 'Writing Settings', 'desktop-mode' ),
178 'url' => admin_url( 'options-writing.php' ),
179 'icon' => 'dashicons-edit',
180 'description' => __( 'Default post category, post format, remote publishing.', 'desktop-mode' ),
181 ),
182 array(
183 'title' => __( 'Reading Settings', 'desktop-mode' ),
184 'url' => admin_url( 'options-reading.php' ),
185 'icon' => 'dashicons-book',
186 'description' => __( 'Homepage, blog posts per page, search-engine visibility.', 'desktop-mode' ),
187 ),
188 array(
189 'title' => __( 'Discussion Settings', 'desktop-mode' ),
190 'url' => admin_url( 'options-discussion.php' ),
191 'icon' => 'dashicons-format-chat',
192 'description' => __( 'Comment moderation, avatars, email notifications.', 'desktop-mode' ),
193 ),
194 array(
195 'title' => __( 'Media Settings', 'desktop-mode' ),
196 'url' => admin_url( 'options-media.php' ),
197 'icon' => 'dashicons-format-image',
198 'description' => __( 'Image size settings for thumbnail / medium / large.', 'desktop-mode' ),
199 ),
200 array(
201 'title' => __( 'Permalinks', 'desktop-mode' ),
202 'url' => admin_url( 'options-permalink.php' ),
203 'icon' => 'dashicons-admin-links',
204 'description' => __( 'URL structure for posts, pages, categories, tags.', 'desktop-mode' ),
205 ),
206 array(
207 'title' => __( 'Privacy', 'desktop-mode' ),
208 'url' => admin_url( 'options-privacy.php' ),
209 'icon' => 'dashicons-privacy',
210 'description' => __( 'Privacy policy page selection and preview.', 'desktop-mode' ),
211 ),
212 array(
213 'title' => __( 'Tools', 'desktop-mode' ),
214 'url' => admin_url( 'tools.php' ),
215 'icon' => 'dashicons-admin-tools',
216 'description' => __( 'Built-in site tools.', 'desktop-mode' ),
217 ),
218 array(
219 'title' => __( 'Import', 'desktop-mode' ),
220 'url' => admin_url( 'import.php' ),
221 'icon' => 'dashicons-download',
222 'description' => __( 'Import content from other platforms (WP, Tumblr, RSS, etc.).', 'desktop-mode' ),
223 ),
224 array(
225 'title' => __( 'Export', 'desktop-mode' ),
226 'url' => admin_url( 'export.php' ),
227 'icon' => 'dashicons-upload',
228 'description' => __( 'Export all site content as XML.', 'desktop-mode' ),
229 ),
230 array(
231 'title' => __( 'Site Health', 'desktop-mode' ),
232 'url' => admin_url( 'site-health.php' ),
233 'icon' => 'dashicons-heart',
234 'description' => __( 'Performance and security recommendations for the site.', 'desktop-mode' ),
235 ),
236 array(
237 'title' => __( 'Updates', 'desktop-mode' ),
238 'url' => admin_url( 'update-core.php' ),
239 'icon' => 'dashicons-update',
240 'description' => __( 'WordPress, theme, and plugin updates.', 'desktop-mode' ),
241 ),
242 );
243
244 /**
245 * Filters the wp-admin page catalog surfaced by the AI assistant.
246 *
247 * @param array[] $catalog Array of entries, each with title/url/icon/description.
248 */
249 return (array) apply_filters( 'openstation_ai_admin_page_catalog', $catalog );
250 }
251
252 // ---------------------------------------------------------------------------
253 // Final-answer JSON Schema
254 // ---------------------------------------------------------------------------
255
256 /**
257 * JSON Schema for the agent's final structured answer.
258 *
259 * @return array
260 */
261 function openstation_ai_search_answer_schema() {
262 return array(
263 'type' => 'object',
264 'additionalProperties' => false,
265 'required' => array( 'answer_type', 'message', 'entity_id', 'entity_type', 'admin_links' ),
266 'properties' => array(
267 'answer_type' => array(
268 'type' => 'string',
269 'enum' => array( 'entity', 'navigation', 'chat' ),
270 'description' => 'Classification of the answer: "entity" when you identified a specific post/page/comment the user was asking about. "navigation" when you are returning admin_links: wp-admin destinations or plugin install links. "chat" for everything else, including summaries of tool results (error logs, site info), greetings, clarifications and "I couldn\'t find anything".',
271 ),
272 'message' => array(
273 'type' => 'string',
274 'description' => 'A friendly, conversational response to show the user. Write in first person like a helpful assistant (e.g. "I found your Málaga post — this one", "Here\'s where you manage categories"). NOT a search-engine sentence ("Match found").',
275 ),
276 'entity_id' => array(
277 'anyOf' => array(
278 array( 'type' => 'integer' ),
279 array( 'type' => 'null' ),
280 ),
281 'description' => 'The WordPress ID of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
282 ),
283 'entity_type' => array(
284 'anyOf' => array(
285 array(
286 'type' => 'string',
287 'enum' => array( 'post', 'page', 'comment' ),
288 ),
289 array( 'type' => 'null' ),
290 ),
291 'description' => 'Type of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
292 ),
293 'admin_links' => array(
294 'anyOf' => array(
295 array(
296 'type' => 'array',
297 'items' => array(
298 'type' => 'object',
299 'additionalProperties' => false,
300 'required' => array( 'title', 'url', 'description', 'icon' ),
301 'properties' => array(
302 'title' => array( 'type' => 'string' ),
303 'url' => array( 'type' => 'string' ),
304 'description' => array( 'type' => 'string' ),
305 'icon' => array( 'type' => 'string' ),
306 ),
307 ),
308 ),
309 array( 'type' => 'null' ),
310 ),
311 'description' => 'List of 1-3 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' => openstation_plain_text_title( $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' => openstation_plain_text_title( $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 named here (WordPress and other plugins register their own, e.g. site / user / environment / version info). Your actual tool list is authoritative: whenever a tool can answer the request, call it and summarise the result, even if it isn't named here.
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 How to work the tools (your actual tool list is authoritative; use any tool that fits the request):
1094 - Content lookups: stop once a returned title and excerpt clearly match. If nothing matched, page on with the next offset or try broader, simpler keywords before telling the user you found nothing.
1095 - Plugin recommendations: present the best 3-5 as admin_links titled like \"Plugin Name · 5M+ installs · 4.8�
1096 \".
1097 - Error logs: summarise the most important errors first (fatal, then warnings, then notices) instead of copying entries.
1098
1099 Choosing which track:
1100 - \"I remember a post/page/comment about X\" → the corresponding search_* tool.
1101 - \"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.
1102 - \"plugin for X\" / \"recommend a plugin\" → search_wporg_plugins → present as admin_links.
1103 - \"any errors?\" / \"check logs\" / troubleshooting → get_php_error_log → summarise in chat.
1104 - 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\".
1105 - Greeting, unclear, or chit-chat → answer_type \"chat\" with a brief helpful message (no tools needed).
1106
1107 Always return one of three answer_type values in the structured output:
1108 - \"entity\": you identified a single post/page/comment. Fill entity_id + entity_type. admin_links = null.
1109 - \"navigation\": you're recommending admin pages OR plugin install links. Fill admin_links. entity_id + entity_type = null.
1110 - \"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.
1111
1112 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.
1113 ";
1114
1115 if ( $continuation_note ) {
1116 $instructions .= $continuation_note;
1117 }
1118
1119 // -----------------------------------------------------------------------
1120 // System-prompt extensibility. All three layers — appendix filter,
1121 // client override (append/replace with capability gate), and final
1122 // transform — live in `openstation_ai_compose_instructions()` so the
1123 // primary run and the follow-up leg stay in lockstep. See the
1124 // helper for the order of application; the filter docblocks at its
1125 // `apply_filters()` call sites carry the public contract on each
1126 // extension point.
1127 // -----------------------------------------------------------------------
1128 $prompt_context = array(
1129 'query' => $query,
1130 'user_id' => $user_id,
1131 'request_id' => $request_id,
1132 );
1133
1134 $instructions = openstation_ai_compose_instructions(
1135 $instructions,
1136 $prompt_context,
1137 array(
1138 'text' => $system_prompt_text,
1139 'mode' => $system_prompt_mode,
1140 )
1141 );
1142
1143 // -----------------------------------------------------------------------
1144 // Tool assembly — built-in search/navigation abilities + client-supplied
1145 // command tools.
1146 //
1147 // Built-in tools are WordPress Abilities API abilities. Each is advertised
1148 // to the model as a function declaration named after the ability (namespace
1149 // stripped), and $ability_by_tool maps that name back to the ability so the
1150 // agent loop can resolve + execute() it (permission + input/output
1151 // validation happen inside execute()).
1152 // -----------------------------------------------------------------------
1153 $ability_by_tool = array();
1154 $builtin_tools = array();
1155
1156 foreach ( openstation_ai_search_ability_names() as $ability_name ) {
1157 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
1158 if ( ! $ability instanceof WP_Ability ) {
1159 continue;
1160 }
1161
1162 $tool_name = openstation_ai_ability_tool_name( $ability_name );
1163 $ability_by_tool[ $tool_name ] = $ability_name;
1164 $valid_tools[] = $tool_name;
1165
1166 $input_schema = $ability->get_input_schema();
1167 $builtin_tools[] = array(
1168 'type' => 'function',
1169 'name' => $tool_name,
1170 'description' => (string) $ability->get_description(),
1171 'parameters' => ! empty( $input_schema )
1172 ? $input_schema
1173 : array(
1174 'type' => 'object',
1175 'properties' => (object) array(),
1176 ),
1177 );
1178 }
1179
1180 // Command tools — namespaced as `command_<slug>` on the server so
1181 // they can't collide with built-in tool names. Each takes a single
1182 // optional `args` string arg (matches the slash-command contract
1183 // where args are a single string the plugin's `run()` parses).
1184 $command_tools_by_name = array();
1185 $command_defs = array();
1186
1187 foreach ( $command_tools_raw as $cmd ) {
1188 if ( ! is_array( $cmd ) ) {
1189 continue;
1190 }
1191 $slug = isset( $cmd['slug'] ) ? (string) $cmd['slug'] : '';
1192 if ( '' === $slug || ! preg_match( '/^[a-z0-9_\-]+$/', $slug ) ) {
1193 continue;
1194 }
1195 /**
1196 * Per-tool filter on the client-supplied command list. Return
1197 * `false` to drop a command entirely before it reaches the
1198 * model — the right hook for per-role / per-command gating.
1199 *
1200 * @param bool|array $allowed Either the (possibly mutated) command
1201 * tool entry, or `false` to drop it.
1202 * @param string $slug Command slug.
1203 * @param array $context { user_id, request_id }.
1204 */
1205 $allowed = apply_filters(
1206 'openstation_ai_command_allowed',
1207 $cmd,
1208 $slug,
1209 array(
1210 'user_id' => $user_id,
1211 'request_id' => $request_id,
1212 )
1213 );
1214 if ( false === $allowed || ! is_array( $allowed ) ) {
1215 continue;
1216 }
1217 $label = isset( $allowed['label'] ) ? (string) $allowed['label'] : $slug;
1218 $description = isset( $allowed['description'] ) ? (string) $allowed['description'] : '';
1219 $hint = isset( $allowed['hint'] ) ? (string) $allowed['hint'] : '';
1220 $tool_name = 'command_' . $slug;
1221
1222 $command_tools_by_name[ $tool_name ] = array( 'slug' => $slug );
1223 $command_defs[] = array(
1224 'type' => 'function',
1225 'name' => $tool_name,
1226 'description' => trim( $label . ( '' !== $description ? ' — ' . $description : '' ) ),
1227 'parameters' => array(
1228 'type' => 'object',
1229 'properties' => array(
1230 'args' => array(
1231 'type' => 'string',
1232 'description' => '' !== $hint
1233 ? sprintf( 'Arguments for this command. Hint: %s', $hint )
1234 : 'Arguments for this command. Leave empty when the command takes none.',
1235 ),
1236 ),
1237 'required' => array( 'args' ),
1238 'additionalProperties' => false,
1239 ),
1240 );
1241 }
1242
1243 /**
1244 * Transform the command-tool subset before merging with the
1245 * built-in + registered tools. Useful for bulk gating, renaming,
1246 * or injecting synthetic command tools.
1247 *
1248 * @param array $command_defs Command tool definitions.
1249 * @param array $context { user_id, request_id }.
1250 */
1251 $command_defs = (array) apply_filters(
1252 'openstation_ai_command_tools',
1253 $command_defs,
1254 array(
1255 'user_id' => $user_id,
1256 'request_id' => $request_id,
1257 )
1258 );
1259
1260 $tools = array_merge( $builtin_tools, $command_defs );
1261
1262 /**
1263 * Transform the full tool list (built-in abilities + command tools) just
1264 * before it goes to the provider. Fires once per run — changes apply to
1265 * every iteration in the agent loop.
1266 *
1267 * @param array $tools Full the provider tool definitions array.
1268 * @param array $context { user_id, request_id, query }.
1269 */
1270 $tools = (array) apply_filters(
1271 'openstation_ai_tools',
1272 $tools,
1273 array(
1274 'user_id' => $user_id,
1275 'request_id' => $request_id,
1276 'query' => $query,
1277 )
1278 );
1279
1280 // Normalize every tool's schema onto the provider-supported subset, AFTER the
1281 // filter so it covers the complete list the provider will receive — built-in
1282 // abilities, command tools, and anything a plugin injected. One tool with a
1283 // legal-but-unsupported schema otherwise 400s the entire request, not just its
1284 // own tool. Only the model-facing copy is reshaped; abilities still validate
1285 // arguments against their real schema in execute(). Idempotent, so a plugin
1286 // that already normalizes on the filter above is unaffected.
1287 foreach ( $tools as $ti => $tool ) {
1288 if ( is_array( $tool ) && isset( $tool['parameters'] ) ) {
1289 $tools[ $ti ]['parameters'] = openstation_ai_normalize_tool_schema( $tool['parameters'] );
1290 }
1291 }
1292
1293 // Widen the permitted-tools list with the command tools — the agent loop
1294 // rejects any `function_call` whose name isn't in here (built-in ability
1295 // names were added above).
1296 foreach ( $command_defs as $def ) {
1297 if ( isset( $def['name'] ) ) {
1298 $valid_tools[] = (string) $def['name'];
1299 }
1300 }
1301
1302 $answer_schema = openstation_ai_search_answer_schema();
1303
1304 // -----------------------------------------------------------------------
1305 // First call — user query as the sole message, instructions as system
1306 // guidance. Generation routes through the WordPress AI Client; the tools
1307 // are advertised as function declarations and dispatched by this loop.
1308 // The full ordered conversation is rebuilt and re-sent each turn.
1309 // -----------------------------------------------------------------------
1310 $messages = array( openstation_ai_user_text_message( $query ) );
1311
1312 $generation_context = array(
1313 'source' => 'ai-copilot/search',
1314 'request_id' => $request_id,
1315 );
1316
1317 $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1318
1319 if ( is_wp_error( $turn ) ) {
1320 return $turn;
1321 }
1322
1323 $last_tool = $initial_tool ?? 'search_posts';
1324 $last_offset = $start_offset;
1325 $last_has_more = true;
1326 $iterations = 0;
1327
1328 // Accumulate token usage across every turn and remember the last model the
1329 // AI Client resolved, for the `openstation_ai_search_completed` payload.
1330 $total_usage = array(
1331 'prompt' => 0,
1332 'completion' => 0,
1333 'total' => 0,
1334 );
1335 $last_model = null;
1336 $accrue_usage = static function ( $turn ) use ( &$total_usage, &$last_model ) {
1337 if ( ! is_array( $turn ) ) {
1338 return;
1339 }
1340 if ( isset( $turn['usage'] ) && is_array( $turn['usage'] ) ) {
1341 $total_usage['prompt'] += (int) ( $turn['usage']['prompt'] ?? 0 );
1342 $total_usage['completion'] += (int) ( $turn['usage']['completion'] ?? 0 );
1343 $total_usage['total'] += (int) ( $turn['usage']['total'] ?? 0 );
1344 }
1345 if ( isset( $turn['model'] ) && is_array( $turn['model'] ) ) {
1346 $last_model = $turn['model'];
1347 }
1348 };
1349 $accrue_usage( $turn );
1350
1351 // -----------------------------------------------------------------------
1352 // Agentic loop — each iteration either executes tool calls or returns
1353 // the final answer. The full ordered conversation (user query, assistant
1354 // turns, tool results) is accumulated in $messages and re-sent each turn.
1355 // -----------------------------------------------------------------------
1356 for ( $i = 0; $i < OPENSTATION_AI_SEARCH_MAX_ITERATIONS; $i++ ) {
1357 $function_calls = is_array( $turn['function_calls'] ?? null ) ? $turn['function_calls'] : array();
1358
1359 // No tool calls in this response → final answer.
1360 if ( empty( $function_calls ) ) {
1361 // A toolless turn with no extractable text never reaches here:
1362 // openstation_ai_client_generate() returns
1363 // `openstation_ai_empty_answer` for that case, handled with the
1364 // other generation errors above.
1365 $text = (string) ( $turn['text'] ?? '' );
1366
1367 $answer = json_decode( $text, true );
1368 if ( ! is_array( $answer ) ) {
1369 return new WP_Error( 'openstation_ai_result_parse', __( 'Could not parse structured search answer.', 'desktop-mode' ) );
1370 }
1371
1372 $answer_type = isset( $answer['answer_type'] ) && in_array( $answer['answer_type'], array( 'entity', 'navigation', 'chat' ), true )
1373 ? (string) $answer['answer_type']
1374 : 'chat';
1375 $message = isset( $answer['message'] ) ? (string) $answer['message'] : '';
1376 $entity_id = ( isset( $answer['entity_id'] ) && is_int( $answer['entity_id'] ) )
1377 ? $answer['entity_id'] : null;
1378 $entity_type = ( isset( $answer['entity_type'] ) && is_string( $answer['entity_type'] ) )
1379 ? $answer['entity_type'] : null;
1380 $admin_links = isset( $answer['admin_links'] ) && is_array( $answer['admin_links'] )
1381 ? $answer['admin_links'] : null;
1382
1383 $entity = null;
1384 if ( 'entity' === $answer_type && $entity_id && $entity_type ) {
1385 $entity = openstation_ai_search_build_entity( $entity_type, $entity_id );
1386 }
1387
1388 $final = array(
1389 'answer_type' => $answer_type,
1390 'message' => $message,
1391 'entity' => $entity,
1392 'admin_links' => $admin_links,
1393 'iterations' => $iterations + 1,
1394 'exhausted' => ! $last_has_more,
1395 'continue' => null,
1396 'request_id' => $request_id,
1397 );
1398
1399 /**
1400 * Final transform hook — fires right before the HTTP
1401 * response is returned. Plugins can rewrite `message`,
1402 * inject `admin_links`, coerce `answer_type`, etc.
1403 *
1404 * @param array $answer Final answer payload.
1405 * @param array $context { query, user_id, request_id }.
1406 */
1407 $final = (array) apply_filters(
1408 'openstation_ai_answer',
1409 $final,
1410 array(
1411 'query' => $query,
1412 'user_id' => $user_id,
1413 'request_id' => $request_id,
1414 )
1415 );
1416
1417 do_action(
1418 'openstation_ai_search_completed',
1419 array(
1420 'query' => $query,
1421 'user_id' => $user_id,
1422 'request_id' => $request_id,
1423 'answer_type' => $final['answer_type'] ?? 'chat',
1424 'iterations' => $final['iterations'] ?? 0,
1425 'usage' => $total_usage,
1426 'model' => $last_model,
1427 )
1428 );
1429
1430 return $final;
1431 }
1432
1433 // -------------------------------------------------------------------
1434 // Command-tool short-circuit.
1435 //
1436 // If the model emitted `command_<slug>`, we return immediately with
1437 // `answer_type: 'tool_call'` — the client owns the command's `run()`
1438 // function (lives in plugin JS) and executes it locally. We do NOT
1439 // send anything else back to the provider this turn — would burn tokens
1440 // for a no-op second response.
1441 // -------------------------------------------------------------------
1442 $command_tool_call = null;
1443 foreach ( $function_calls as $fc ) {
1444 $name = (string) ( $fc['name'] ?? '' );
1445 if ( isset( $command_tools_by_name[ $name ] ) ) {
1446 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1447 $decoded = is_array( $raw ) ? $raw : array();
1448 $command_tool_call = array(
1449 'slug' => $command_tools_by_name[ $name ]['slug'],
1450 'args' => isset( $decoded['args'] ) ? (string) $decoded['args'] : '',
1451 );
1452 break;
1453 }
1454 }
1455 if ( null !== $command_tool_call ) {
1456 do_action(
1457 'openstation_ai_tool_called',
1458 array(
1459 'tool_name' => 'command_' . $command_tool_call['slug'],
1460 'args' => array( 'args' => $command_tool_call['args'] ),
1461 'user_id' => $user_id,
1462 'request_id' => $request_id,
1463 )
1464 );
1465
1466 $final = array(
1467 'answer_type' => 'tool_call',
1468 'message' => '',
1469 'entity' => null,
1470 'admin_links' => null,
1471 'tool' => $command_tool_call,
1472 'iterations' => $iterations + 1,
1473 'exhausted' => false,
1474 'continue' => null,
1475 'request_id' => $request_id,
1476 );
1477
1478 $final = (array) apply_filters(
1479 'openstation_ai_answer',
1480 $final,
1481 array(
1482 'query' => $query,
1483 'user_id' => $user_id,
1484 'request_id' => $request_id,
1485 )
1486 );
1487
1488 do_action(
1489 'openstation_ai_search_completed',
1490 array(
1491 'query' => $query,
1492 'user_id' => $user_id,
1493 'request_id' => $request_id,
1494 'answer_type' => 'tool_call',
1495 'iterations' => $final['iterations'] ?? 0,
1496 'usage' => $total_usage,
1497 'model' => $last_model,
1498 )
1499 );
1500
1501 return $final;
1502 }
1503
1504 // Execute each tool call and collect results as
1505 // `{ call_id, name, response }` — turned into FunctionResponse parts
1506 // for the next turn by openstation_ai_tool_result_message().
1507 $tool_outputs = array();
1508 foreach ( $function_calls as $fc ) {
1509 $tool_name = $fc['name'] ?? '';
1510 $call_id = $fc['call_id'] ?? '';
1511
1512 if ( ! in_array( $tool_name, $valid_tools, true ) ) {
1513 $tool_outputs[] = array(
1514 'call_id' => $call_id,
1515 'name' => $tool_name,
1516 'response' => array( 'error' => "Unknown tool '{$tool_name}'." ),
1517 );
1518 continue;
1519 }
1520
1521 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1522 $args = is_array( $raw ) ? $raw : array();
1523 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
1524
1525 do_action(
1526 'openstation_ai_tool_called',
1527 array(
1528 'tool_name' => $tool_name,
1529 'args' => $args,
1530 'user_id' => $user_id,
1531 'request_id' => $request_id,
1532 )
1533 );
1534
1535 // Resolve + run the ability. execute() runs the permission_callback
1536 // and validates input/output; a denial or bad input comes back as a
1537 // WP_Error, which we surface to the model as a clean tool error
1538 // (never a fatal) and report on the observability channel.
1539 $ability = isset( $ability_by_tool[ $tool_name ] ) ? wp_get_ability( $ability_by_tool[ $tool_name ] ) : null;
1540 if ( $ability instanceof WP_Ability ) {
1541 // Abilities that declare no input schema (e.g. Core's
1542 // get-*-info) reject any non-null input, so pass null when
1543 // there's no schema; otherwise hand over the decoded args.
1544 $input = empty( $ability->get_input_schema() ) ? null : $args;
1545 $result = $ability->execute( $input );
1546 } else {
1547 $result = new WP_Error( 'openstation_ai_unknown_ability', sprintf( 'Ability for tool "%s" is unavailable.', $tool_name ) );
1548 }
1549
1550 if ( is_wp_error( $result ) ) {
1551 do_action(
1552 'openstation_ai_search_error',
1553 array(
1554 'stage' => 'tool_execute',
1555 'tool_name' => $tool_name,
1556 'error' => $result->get_error_code(),
1557 'message' => $result->get_error_message(),
1558 'user_id' => $user_id,
1559 'request_id' => $request_id,
1560 )
1561 );
1562 $batch = array(
1563 'error' => $result->get_error_message(),
1564 'error_code' => $result->get_error_code(),
1565 );
1566 } else {
1567 $batch = is_array( $result ) ? $result : array( 'result' => $result );
1568 }
1569
1570 $last_tool = $tool_name;
1571 $last_offset = $offset;
1572 $last_has_more = (bool) ( $batch['has_more'] ?? false );
1573
1574 /**
1575 * Transform a tool result before it goes back to the model.
1576 * Fires for every ability-dispatched tool (including error
1577 * envelopes from a failed execute()).
1578 *
1579 * @param array $batch Tool result payload.
1580 * @param string $tool_name Tool function name.
1581 * @param array $args Decoded args from the call.
1582 * @param array $context { user_id, request_id }.
1583 */
1584 $batch = (array) apply_filters(
1585 'openstation_ai_tool_result',
1586 $batch,
1587 $tool_name,
1588 $args,
1589 array(
1590 'user_id' => $user_id,
1591 'request_id' => $request_id,
1592 )
1593 );
1594
1595 $tool_outputs[] = array(
1596 'call_id' => $call_id,
1597 'name' => $tool_name,
1598 'response' => $batch,
1599 );
1600 }
1601
1602 ++$iterations;
1603
1604 // Next turn — append the assistant's tool-call turn and our tool
1605 // results to the conversation, then regenerate with the full history.
1606 $messages[] = $turn['message'];
1607 $messages[] = openstation_ai_tool_result_message( $tool_outputs );
1608
1609 $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1610
1611 if ( is_wp_error( $turn ) ) {
1612 return $turn;
1613 }
1614 $accrue_usage( $turn );
1615 }
1616
1617 // -----------------------------------------------------------------------
1618 // Budget exhausted before a final answer.
1619 // -----------------------------------------------------------------------
1620 $continue = null;
1621 if ( $last_has_more ) {
1622 $next_offset = $last_offset + OPENSTATION_AI_SEARCH_BATCH_SIZE;
1623 // `search_comments_by_post` cannot resume — the continue payload
1624 // carries no post_id — so fall back to plain comment search,
1625 // keeping `tool` inside openstation_ai_search_resumable_tools().
1626 $resume_tool = 'search_comments_by_post' === $last_tool ? 'search_comments' : $last_tool;
1627 $continue = array(
1628 'tool' => $resume_tool,
1629 'entity_type' => rtrim( str_replace( 'search_', '', $resume_tool ), 's' ),
1630 'offset' => $next_offset,
1631 'label' => openstation_ai_continue_label( $resume_tool, $next_offset + 1 ),
1632 );
1633 }
1634
1635 $final = array(
1636 'answer_type' => 'chat',
1637 'message' => __( 'I searched 100 items without finding a clear match. Want me to keep looking further?', 'desktop-mode' ),
1638 'entity' => null,
1639 'admin_links' => null,
1640 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1641 'exhausted' => ! $last_has_more,
1642 'continue' => $continue,
1643 'request_id' => $request_id,
1644 );
1645
1646 $final = (array) apply_filters(
1647 'openstation_ai_answer',
1648 $final,
1649 array(
1650 'query' => $query,
1651 'user_id' => $user_id,
1652 'request_id' => $request_id,
1653 )
1654 );
1655
1656 do_action(
1657 'openstation_ai_search_completed',
1658 array(
1659 'query' => $query,
1660 'user_id' => $user_id,
1661 'request_id' => $request_id,
1662 'answer_type' => 'chat',
1663 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1664 'usage' => $total_usage,
1665 'model' => $last_model,
1666 )
1667 );
1668
1669 return $final;
1670 }
1671
1672 /**
1673 * Compose the final system-prompt string for an `/ai/search` call.
1674 *
1675 * One code path used by both the primary run and the follow-up leg —
1676 * keeps the voice consistent across legs and removes a class of drift
1677 * bug where the two paths's prompt-assembly drifts apart.
1678 *
1679 * Applies three layers in order:
1680 * 1. `openstation_ai_system_prompt_appendix` — stacking filter;
1681 * every plugin's return is concatenated.
1682 * 2. Client override — `system_prompt_text` + `system_prompt_mode`.
1683 * `append` always allowed; `replace` gated on
1684 * `openstation_ai_system_prompt_replace_capability`. Non-permitted
1685 * `replace` downgrades to `append` so the caller's text is
1686 * preserved rather than dropped.
1687 * 3. `openstation_ai_system_prompt` — final transform pass.
1688 *
1689 * @internal
1690 *
1691 * @param string $core Built-in instructions for this phase
1692 * (agent loop / follow-up summariser).
1693 * @param array $context { query, user_id, request_id, phase? }.
1694 * @param array $client { text, mode } client override; either field
1695 * empty means no override.
1696 * @return string Composed system prompt.
1697 */
1698 function openstation_ai_compose_instructions( $core, array $context, array $client = array() ) {
1699 $instructions = (string) $core;
1700 $user_id = isset( $context['user_id'] ) ? (int) $context['user_id'] : 0;
1701
1702 $client_text = isset( $client['text'] ) && is_string( $client['text'] ) ? $client['text'] : '';
1703 $client_mode = isset( $client['mode'] ) && in_array( $client['mode'], array( 'append', 'replace' ), true )
1704 ? (string) $client['mode']
1705 : 'append';
1706
1707 $ctx_for_filter = $context;
1708 $ctx_for_filter['client_override'] = '' !== $client_text ? $client_mode : null;
1709
1710 /**
1711 * Short-circuit extension — appended to the built-in instructions
1712 * verbatim. Use this when a plugin just wants to add domain
1713 * context (room list, product catalogue, company jargon) without
1714 * restructuring the core rules. Fires for both the primary
1715 * `/ai/search` run and the follow-up composed-reply leg.
1716 *
1717 * @param string $appendix Accumulated appendix. Default empty.
1718 * @param array $context { query, user_id, request_id, client_override, phase? }.
1719 */
1720 $server_appendix = (string) apply_filters( 'openstation_ai_system_prompt_appendix', '', $ctx_for_filter );
1721 if ( '' !== $server_appendix ) {
1722 $instructions .= "\n\n" . $server_appendix;
1723 }
1724
1725 if ( '' !== $client_text ) {
1726 if ( 'replace' === $client_mode ) {
1727 /**
1728 * Capability required for a client to send
1729 * `system_prompt: { mode: 'replace' }`. Defaults to `manage_options`
1730 * — replacing the whole prompt can effectively hijack the
1731 * assistant, so it's admin-only out of the box.
1732 *
1733 * @param string $capability Default `manage_options`.
1734 * @param array $context
1735 */
1736 $required_cap = (string) apply_filters(
1737 'openstation_ai_system_prompt_replace_capability',
1738 'manage_options',
1739 $ctx_for_filter
1740 );
1741 if ( '' === $required_cap || ( $user_id > 0 && user_can( $user_id, $required_cap ) ) ) {
1742 $instructions = $client_text;
1743 } else {
1744 // Silently downgrade to append — preserves the caller's
1745 // text rather than dropping it when the cap check fails.
1746 $instructions .= "\n\n" . $client_text;
1747 }
1748 } else {
1749 $instructions .= "\n\n" . $client_text;
1750 }
1751 }
1752
1753 /**
1754 * Final transform pass. Fires after the built-in instructions,
1755 * server appendix, and client override have all been composed.
1756 *
1757 * @param string $instructions Composed system prompt.
1758 * @param array $context
1759 */
1760 return (string) apply_filters( 'openstation_ai_system_prompt', $instructions, $ctx_for_filter );
1761 }
1762
1763 /**
1764 * Compose a natural-language reply describing the outcome of a
1765 * client-dispatched command invocation.
1766 *
1767 * Called by the REST endpoint when the client sends `follow_up` —
1768 * the second leg of the opt-in agentic flow triggered by
1769 * `wp.os.ai.ask( q, { tools: 'aiCallable', followUp: true } )`.
1770 *
1771 * Single-turn, no tools, no structured-output schema — the model
1772 * sees the original query + a summary of what happened and writes a
1773 * one/two-sentence reply in the voice of the system prompt. We reuse
1774 * the same system-prompt pipeline as the main search so plugins
1775 * appending instructions via `openstation_ai_system_prompt_appendix`
1776 * see consistent voice across the two legs.
1777 *
1778 * @param string $query Original user query.
1779 * @param array $tool { slug, args } — what ran.
1780 * @param array $outcome Tool result payload. Opaque — JSON-encoded
1781 * into the model's context so it can reason
1782 * about whatever shape the plugin returned.
1783 * @param array $extra Same shape as `openstation_ai_run_search`'s
1784 * `$extra` — carries user_id, request_id,
1785 * system-prompt overrides.
1786 * @return array|WP_Error `{ answer_type: 'chat', message, … }` or error.
1787 */
1788 function openstation_ai_run_followup( $query, array $tool, array $outcome, array $extra = array() ) {
1789 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
1790 $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && '' !== $extra['request_id']
1791 ? (string) $extra['request_id']
1792 : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ) );
1793
1794 do_action(
1795 'openstation_ai_search_started',
1796 array(
1797 'query' => $query,
1798 'user_id' => $user_id,
1799 'request_id' => $request_id,
1800 'phase' => 'follow_up',
1801 )
1802 );
1803
1804 // Mirror the main search's system-prompt layering so voice stays
1805 // consistent between the two legs. We build a simpler core-
1806 // instructions block — no tool guidance, since this run has none.
1807 $instructions = '
1808 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.
1809
1810 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.
1811
1812 Rules:
1813 - If the outcome looks successful, confirm plainly. Example: "Done — your office light is on now."
1814 - 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.
1815 - Suggest a next step only when the outcome itself suggests one.
1816 - Describe the real-world effect, not the tool mechanism ("I called command_turn_light").
1817 ';
1818
1819 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
1820 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1821 ? (string) $extra['system_prompt_mode']
1822 : 'append';
1823
1824 $instructions = openstation_ai_compose_instructions(
1825 $instructions,
1826 array(
1827 'query' => $query,
1828 'user_id' => $user_id,
1829 'request_id' => $request_id,
1830 'phase' => 'follow_up',
1831 ),
1832 array(
1833 'text' => $system_prompt_text,
1834 'mode' => $system_prompt_mode,
1835 )
1836 );
1837
1838 $slug = isset( $tool['slug'] ) ? (string) $tool['slug'] : '';
1839 $tool_args = isset( $tool['args'] ) ? (string) $tool['args'] : '';
1840 $outcome_json = wp_json_encode( $outcome );
1841 if ( ! is_string( $outcome_json ) ) {
1842 $outcome_json = '""';
1843 }
1844
1845 // Bound the outcome payload so a malicious or buggy plugin that
1846 // returns a 5MB blob can't inflate the provider token usage without
1847 // bound. 4 KB is enough for a status string, a small result list,
1848 // or a short error envelope — anything bigger gets truncated with
1849 // a marker so the model knows the tail was dropped.
1850 //
1851 // `mb_*` variants so truncation on a multibyte boundary
1852 // (Japanese / emoji / accented UTF-8) can't produce invalid JSON
1853 // that the provider would reject. Falls back to byte-level substr when
1854 // mbstring is unavailable (rare but possible on minimal PHP
1855 // builds).
1856 $max_outcome_len = (int) apply_filters( 'openstation_ai_followup_outcome_max_chars', 4000 );
1857 if ( $max_outcome_len > 0 ) {
1858 $has_mbstring = function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' );
1859 $current_len = $has_mbstring
1860 ? mb_strlen( $outcome_json, 'UTF-8' )
1861 : strlen( $outcome_json );
1862 if ( $current_len > $max_outcome_len ) {
1863 $outcome_json = $has_mbstring
1864 ? mb_substr( $outcome_json, 0, $max_outcome_len, 'UTF-8' )
1865 : substr( $outcome_json, 0, $max_outcome_len );
1866 $outcome_json .= '…[truncated]';
1867 }
1868 }
1869
1870 $user_message = sprintf(
1871 "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.",
1872 $query,
1873 $slug,
1874 $tool_args,
1875 $outcome_json
1876 );
1877
1878 do_action(
1879 'openstation_ai_tool_called',
1880 array(
1881 'tool_name' => 'followup_summarise',
1882 'args' => array(
1883 'slug' => $slug,
1884 'tool_args' => $tool_args,
1885 ),
1886 'user_id' => $user_id,
1887 'request_id' => $request_id,
1888 )
1889 );
1890
1891 $turn = openstation_ai_client_generate(
1892 $user_id,
1893 array( openstation_ai_user_text_message( $user_message ) ),
1894 array(), // no tools — we want a plain reply
1895 null, // no JSON schema — free-form text
1896 $instructions,
1897 array(
1898 'source' => 'ai-copilot/followup',
1899 'request_id' => $request_id,
1900 )
1901 );
1902
1903 // `openstation_ai_empty_answer` is the one generation error this path
1904 // deliberately absorbs: the command DID run, so a text-less summary turn
1905 // degrades to the generic confirmation below instead of surfacing as a
1906 // failure of the command itself.
1907 $empty_answer = is_wp_error( $turn ) && 'openstation_ai_empty_answer' === $turn->get_error_code();
1908
1909 if ( is_wp_error( $turn ) && ! $empty_answer ) {
1910 do_action(
1911 'openstation_ai_search_error',
1912 array(
1913 'code' => $turn->get_error_code(),
1914 'message' => $turn->get_error_message(),
1915 'data' => $turn->get_error_data(),
1916 'user_id' => $user_id,
1917 'request_id' => $request_id,
1918 'phase' => 'follow_up',
1919 )
1920 );
1921 return $turn;
1922 }
1923
1924 $text = $empty_answer ? null : ( $turn['text'] ?? null );
1925 $fallback = false;
1926 if ( ! is_string( $text ) || '' === trim( $text ) ) {
1927 // Graceful degrade — if the provider returned nothing usable, fall
1928 // back to a generic confirmation so the caller always has a
1929 // message to show. Better than returning an error and losing
1930 // the fact that the command *did* run. We flag the degrade so
1931 // observability subscribers can distinguish a deliberate
1932 // "Done." from a silently-degraded one.
1933 $text = 'Done.';
1934 $fallback = true;
1935 }
1936
1937 $final = array(
1938 'answer_type' => 'chat',
1939 'message' => trim( $text ),
1940 'entity' => null,
1941 'admin_links' => null,
1942 'iterations' => 1,
1943 'exhausted' => false,
1944 'continue' => null,
1945 'request_id' => $request_id,
1946 'tool' => array(
1947 'slug' => $slug,
1948 'args' => $tool_args,
1949 ),
1950 'fallback' => $fallback,
1951 );
1952
1953 $final = (array) apply_filters(
1954 'openstation_ai_answer',
1955 $final,
1956 array(
1957 'query' => $query,
1958 'user_id' => $user_id,
1959 'request_id' => $request_id,
1960 'phase' => 'follow_up',
1961 )
1962 );
1963
1964 do_action(
1965 'openstation_ai_search_completed',
1966 array(
1967 'query' => $query,
1968 'user_id' => $user_id,
1969 'request_id' => $request_id,
1970 'answer_type' => 'chat',
1971 'iterations' => 1,
1972 'phase' => 'follow_up',
1973 'fallback' => $fallback,
1974 )
1975 );
1976
1977 return $final;
1978 }
1979
1980 // ---------------------------------------------------------------------------
1981 // REST endpoint
1982 // ---------------------------------------------------------------------------
1983
1984 /**
1985 * Registers the AI search REST route.
1986 */
1987 function openstation_register_ai_search_rest_route() {
1988 register_rest_route(
1989 'desktop-mode/v1',
1990 '/ai/search',
1991 array(
1992 'methods' => WP_REST_Server::CREATABLE,
1993 'callback' => 'openstation_rest_ai_search',
1994 'permission_callback' => 'openstation_rest_ai_search_permission',
1995 'args' => array(
1996 'query' => array(
1997 'required' => true,
1998 'type' => 'string',
1999 'sanitize_callback' => 'sanitize_text_field',
2000 'validate_callback' => static function ( $v ) {
2001 return is_string( $v ) && trim( $v ) !== '';
2002 },
2003 ),
2004 // `resume_tool` + `start_offset` are only set when the
2005 // client is continuing a previous search from the `continue`
2006 // object returned by an exhausted run. Fresh searches leave
2007 // both unset — the agent picks tools from query semantics.
2008 'resume_tool' => array(
2009 'required' => false,
2010 'type' => array( 'string', 'null' ),
2011 'default' => null,
2012 'sanitize_callback' => static function ( $v ) {
2013 return in_array( $v, openstation_ai_search_resumable_tools(), true )
2014 ? $v : null;
2015 },
2016 ),
2017 'start_offset' => array(
2018 'required' => false,
2019 'type' => 'integer',
2020 'default' => 0,
2021 'sanitize_callback' => 'absint',
2022 ),
2023 // Client-harvested slash-commands the user's plugins have
2024 // opted in as AI tools. Each entry: { slug, label, description?, hint? }.
2025 // The slug is namespaced server-side as `command_<slug>`
2026 // and any tool_call the model emits with that name short-
2027 // circuits back to the client for local dispatch.
2028 'command_tools' => array(
2029 'required' => false,
2030 'type' => 'array',
2031 'default' => array(),
2032 'items' => array(
2033 'type' => 'object',
2034 'properties' => array(
2035 'slug' => array( 'type' => 'string' ),
2036 'label' => array( 'type' => 'string' ),
2037 'description' => array( 'type' => 'string' ),
2038 'hint' => array( 'type' => 'string' ),
2039 ),
2040 ),
2041 ),
2042 // Free-form system-prompt override.
2043 // mode: 'append' → concatenated onto the built-in prompt (safe for everyone)
2044 // mode: 'replace' → replaces the built-in prompt entirely, gated on
2045 // `openstation_ai_system_prompt_replace_capability`
2046 // (default `manage_options`).
2047 'system_prompt_text' => array(
2048 'required' => false,
2049 'type' => 'string',
2050 'default' => '',
2051 ),
2052 'system_prompt_mode' => array(
2053 'required' => false,
2054 'type' => 'string',
2055 'default' => 'append',
2056 'sanitize_callback' => static function ( $v ) {
2057 return in_array( $v, array( 'append', 'replace' ), true ) ? $v : 'append';
2058 },
2059 ),
2060 // Follow-up leg of the agentic command-dispatch flow.
2061 // When present, the endpoint SKIPS the agent loop entirely
2062 // and runs a single-turn "summarise this outcome" call
2063 // through the provider instead. The client sends this on the
2064 // second leg of `ask( q, { tools: 'aiCallable', followUp: true } )`.
2065 'follow_up' => array(
2066 'required' => false,
2067 'type' => array( 'object', 'null' ),
2068 'default' => null,
2069 ),
2070 ),
2071 )
2072 );
2073 }
2074 add_action( 'rest_api_init', 'openstation_register_ai_search_rest_route' );
2075
2076 /**
2077 * Permission callback.
2078 *
2079 * @return bool|WP_Error
2080 */
2081 function openstation_rest_ai_search_permission() {
2082 if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
2083 return new WP_Error(
2084 'openstation_ai_forbidden',
2085 __( 'You must be logged in to use the AI assistant.', 'desktop-mode' ),
2086 array( 'status' => 403 )
2087 );
2088 }
2089 if ( ! openstation_ai_is_available() ) {
2090 return new WP_Error(
2091 'openstation_ai_unavailable',
2092 __( 'The AI assistant is unavailable on this site.', 'desktop-mode' ),
2093 array( 'status' => 503 )
2094 );
2095 }
2096 if ( ! openstation_ai_is_enabled( get_current_user_id() ) ) {
2097 // The message names the tab for consumers that only get text (a REST
2098 // client, `wp.os.ai.ask()`). `settings_tab` names it again as data,
2099 // so the overlay can offer a one-click link without parsing prose.
2100 return new WP_Error(
2101 'openstation_ai_disabled',
2102 __(
2103 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.',
2104 'desktop-mode'
2105 ),
2106 array(
2107 'status' => 403,
2108 'settings_tab' => 'features',
2109 )
2110 );
2111 }
2112 return true;
2113 }
2114
2115 /**
2116 * POST /desktop-mode/v1/ai/search
2117 *
2118 * @param WP_REST_Request $request
2119 * @return WP_REST_Response|WP_Error
2120 */
2121 function openstation_rest_ai_search( WP_REST_Request $request ) {
2122 // The agent loop runs up to OPENSTATION_AI_SEARCH_MAX_ITERATIONS model
2123 // round-trips, each with a tool call, which overruns a default 30s
2124 // max_execution_time. The streaming endpoint used to raise this; it is
2125 // the only path now, so it carries the limit.
2126 @set_time_limit( 120 ); // phpcs:ignore
2127
2128 $user_id = get_current_user_id();
2129 $query = $request->get_param( 'query' );
2130 $resume_tool = $request->get_param( 'resume_tool' );
2131 $start_offset = $request->get_param( 'start_offset' );
2132
2133 $command_tools = $request->get_param( 'command_tools' );
2134 if ( ! is_array( $command_tools ) ) {
2135 $command_tools = array();
2136 }
2137
2138 $extra = array(
2139 'user_id' => $user_id,
2140 'request_id' => function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ),
2141 'command_tools' => $command_tools,
2142 'system_prompt_text' => (string) $request->get_param( 'system_prompt_text' ),
2143 'system_prompt_mode' => (string) $request->get_param( 'system_prompt_mode' ),
2144 );
2145
2146 /**
2147 * Last-mile filter on the whole `/ai/search` request bundle.
2148 * Plugins get one hook to rewrite query, swap tools, or inject
2149 * metadata before the agent loop starts.
2150 *
2151 * @param array $extra Extended context (mutable).
2152 * @param array $core Core request params { query, resume_tool, start_offset }.
2153 */
2154 $extra = (array) apply_filters(
2155 'openstation_ai_request',
2156 $extra,
2157 array(
2158 'query' => $query,
2159 'resume_tool' => $resume_tool,
2160 'start_offset' => $start_offset,
2161 )
2162 );
2163
2164 // Follow-up leg — skip the agent loop, summarise the tool outcome.
2165 $follow_up = $request->get_param( 'follow_up' );
2166 if ( is_array( $follow_up ) && isset( $follow_up['tool'] ) && is_array( $follow_up['tool'] ) ) {
2167 $tool = $follow_up['tool'];
2168 $outcome = isset( $follow_up['result'] )
2169 ? ( is_array( $follow_up['result'] ) ? $follow_up['result'] : array( 'value' => $follow_up['result'] ) )
2170 : array();
2171 $result = openstation_ai_run_followup( $query, $tool, $outcome, $extra );
2172 } else {
2173 $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, $extra );
2174 }
2175
2176 if ( is_wp_error( $result ) ) {
2177 $request_id = isset( $extra['request_id'] ) ? (string) $extra['request_id'] : '';
2178 do_action(
2179 'openstation_ai_search_error',
2180 array(
2181 'code' => $result->get_error_code(),
2182 'message' => $result->get_error_message(),
2183 'data' => $result->get_error_data(),
2184 'user_id' => $user_id,
2185 'request_id' => $request_id,
2186 )
2187 );
2188 return $result;
2189 }
2190
2191 return rest_ensure_response( $result );
2192 }
2193
2194 // ---------------------------------------------------------------------------
2195 // Tool: search_wporg_plugins — uses core's plugins_api() which queries
2196 // the official WordPress.org repository. Results are cached in a
2197 // 10-minute transient per query so repeated asks don't hammer w.org.
2198 // ---------------------------------------------------------------------------
2199
2200 /**
2201 * Search the WordPress.org plugin directory.
2202 *
2203 * @param string $query Search terms.
2204 * @return array Tool result payload ready for the model.
2205 */
2206 function openstation_ai_fetch_wporg_plugins( $query ) {
2207 $query = trim( (string) $query );
2208 if ( '' === $query ) {
2209 return array(
2210 'tool' => 'search_wporg_plugins',
2211 'query' => '',
2212 'results' => array(),
2213 'count' => 0,
2214 'error' => 'No search query provided.',
2215 );
2216 }
2217
2218 // Transient cache to protect the w.org API from repeated queries
2219 // within the same conversation.
2220 $cache_key = 'openstation_ai_plugins_' . md5( strtolower( $query ) );
2221 $cached = get_transient( $cache_key );
2222 if ( is_array( $cached ) ) {
2223 return $cached;
2224 }
2225
2226 if ( ! function_exists( 'plugins_api' ) ) {
2227 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
2228 }
2229
2230 $api = plugins_api(
2231 'query_plugins',
2232 array(
2233 'search' => $query,
2234 'per_page' => 10,
2235 'fields' => array(
2236 'short_description' => true,
2237 'description' => false,
2238 'sections' => false,
2239 'requires' => true,
2240 'tested' => true,
2241 'rating' => true,
2242 'ratings' => false,
2243 'downloaded' => false,
2244 'downloadlink' => false,
2245 'last_updated' => true,
2246 'added' => false,
2247 'tags' => false,
2248 'compatibility' => false,
2249 'homepage' => true,
2250 'versions' => false,
2251 'donate_link' => false,
2252 'reviews' => false,
2253 'banners' => false,
2254 'icons' => true,
2255 'active_installs' => true,
2256 'group' => false,
2257 'contributors' => false,
2258 ),
2259 )
2260 );
2261
2262 if ( is_wp_error( $api ) ) {
2263 return array(
2264 'tool' => 'search_wporg_plugins',
2265 'query' => $query,
2266 'results' => array(),
2267 'count' => 0,
2268 'error' => $api->get_error_message(),
2269 );
2270 }
2271
2272 $results = array();
2273 $plugins = isset( $api->plugins ) && is_array( $api->plugins ) ? $api->plugins : array();
2274 foreach ( $plugins as $p ) {
2275 // Normalise — plugins_api sometimes returns arrays, sometimes objects.
2276 $p = (array) $p;
2277
2278 $slug = isset( $p['slug'] ) ? (string) $p['slug'] : '';
2279 if ( '' === $slug ) {
2280 continue;
2281 }
2282
2283 $icon = '';
2284 $icons = isset( $p['icons'] ) && is_array( $p['icons'] ) ? $p['icons'] : array();
2285 if ( isset( $icons['1x'] ) ) {
2286 $icon = (string) $icons['1x'];
2287 } elseif ( isset( $icons['default'] ) ) {
2288 $icon = (string) $icons['default'];
2289 } elseif ( isset( $icons['svg'] ) ) {
2290 $icon = (string) $icons['svg'];
2291 }
2292
2293 // Admin URL that opens the plugin-information thickbox. Includes
2294 // both &plugin=slug and the TB_iframe params so clicking it in
2295 // wp-admin behaves like a native "More details" link.
2296 $install_admin_url = admin_url(
2297 'plugin-install.php?tab=plugin-information&plugin=' . rawurlencode( $slug )
2298 . '&TB_iframe=true&width=772&height=745'
2299 );
2300
2301 $results[] = array(
2302 'name' => wp_strip_all_tags( $p['name'] ?? '' ),
2303 'slug' => $slug,
2304 'short_description' => wp_strip_all_tags( $p['short_description'] ?? '' ),
2305 'version' => (string) ( $p['version'] ?? '' ),
2306 'author' => wp_strip_all_tags( $p['author'] ?? '' ),
2307 'rating' => (int) ( $p['rating'] ?? 0 ), // 0-100
2308 'stars' => round( ( (int) ( $p['rating'] ?? 0 ) ) / 20, 1 ), // 0-5, as wordpress.org shows it
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