PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.5
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.5, at includes/ai-copilot/search.php

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