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

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

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