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

2,202 lines 86.0 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 * Projects a tool's `parameters` schema onto the provider-supported subset.
568 *
569 * The Abilities API (and plugin-supplied tools) may use the full breadth of JSON
570 * Schema, but the provider's tool-schema validator does not — and it rejects the
571 * whole request, not just the offending tool, so ONE tool with a
572 * legal-but-unsupported schema makes the entire assistant return a 400 before the
573 * model runs. Three shapes that are valid JSON Schema, but rejected here, have
574 * been seen in the wild (see the linked issue):
575 *
576 * 1. `type` as an array, e.g. ['object','null'] — an ability's GET/null
577 * run-path. The provider wants the literal string "object" at the top level.
578 * 2. A top-level `oneOf` / `anyOf` / `allOf`, e.g. "post_id OR slug". Rejected
579 * with "does not support oneOf, allOf, or anyOf at the top level".
580 * 3. `properties` as an empty PHP array, which encodes to JSON as `[]` where an
581 * object schema needs `{}`.
582 *
583 * This reshapes only the copy advertised to the model. The ability itself is
584 * untouched: `WP_Ability::execute()` still validates arguments against the real
585 * schema and `permission_callback` still gates execution, so nothing loses
586 * enforcement — the model is simply told the constraint in prose (the tool
587 * description) instead of in a schema construct the provider can't parse. Only
588 * the TOP level is constrained; a combinator nested inside a property is a real
589 * constraint the provider accepts, so it is left intact.
590 *
591 * @since 0.9.6
592 *
593 * @param mixed $schema A tool parameters schema (array), or empty/non-array.
594 * @return array A provider-safe object schema for a tool `parameters` block.
595 */
596 function desktop_mode_ai_normalize_tool_schema( $schema ) {
597 if ( ! is_array( $schema ) || empty( $schema ) ) {
598 return array(
599 'type' => 'object',
600 'properties' => (object) array(),
601 );
602 }
603
604 // Top-level tool parameters must be the literal "object", never a union.
605 $schema['type'] = 'object';
606
607 // Strip top-level combinators — the provider rejects them outright, and one
608 // such tool 400s the whole request. Nested combinators are left alone.
609 unset( $schema['oneOf'], $schema['allOf'], $schema['anyOf'] );
610
611 // An empty PHP array encodes as `[]`; an object schema's properties need `{}`.
612 // A schema with no `properties` at all (e.g. one whose only content was a
613 // stripped top-level combinator) gets an empty object for the same reason.
614 if ( ! isset( $schema['properties'] ) || array() === $schema['properties'] ) {
615 $schema['properties'] = (object) array();
616 }
617
618 return $schema;
619 }
620
621 /**
622 * Runs the agentic content-search loop.
623 *
624 * The model receives focused tools — search_posts, search_pages,
625 * search_comments, search_comments_by_post — and a system prompt that
626 * guides it to choose the right one based on query semantics and pass the
627 * distilled keywords as `query`. "Someone said congratulations" → it calls
628 * search_comments. "I wrote about paella" → it calls search_posts. No
629 * entity_type routing from the caller is needed for a fresh search.
630 *
631 * For continuation runs ($initial_tool + $start_offset > 0), the system
632 * message primes the agent to resume from the last searched position with
633 * the same keywords.
634 *
635 * @since 0.5.0
636 *
637 * @param string $query User's natural-language search.
638 * @param string|null $initial_tool Tool name to resume from, or null for fresh search.
639 * @param int $start_offset Offset to resume from (0 for fresh).
640 * @param callable|null $on_progress Optional progress emitter for SSE ticks.
641 * @param array $extra Extensibility context (command tools, prompt overrides, …).
642 * @return array|WP_Error
643 */
644 function desktop_mode_ai_run_search( $query, $initial_tool = null, $start_offset = 0, $on_progress = null, array $extra = array() ) {
645 /**
646 * Progress emitter — sends a tick to the caller if they provided a
647 * callable; no-op otherwise. Callers use this to render real-time
648 * status to the user via SSE.
649 */
650 $emit = static function ( array $event ) use ( $on_progress ) {
651 if ( is_callable( $on_progress ) ) {
652 $on_progress( $event );
653 }
654 };
655 $start_offset = max( 0, (int) $start_offset );
656 $search_tools = array( 'search_posts', 'search_pages', 'search_comments', 'search_comments_by_post' );
657 $valid_tools = array_merge(
658 $search_tools,
659 array( 'list_admin_pages', 'search_wporg_plugins', 'get_php_error_log' )
660 );
661
662 // -----------------------------------------------------------------------
663 // Extensibility context — command tools from the client, PHP-registered
664 // tools from the server-side registry, system-prompt overrides, and a
665 // per-call request_id for observability fanout.
666 // -----------------------------------------------------------------------
667 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
668 $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && $extra['request_id'] !== ''
669 ? (string) $extra['request_id']
670 : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ) );
671 $command_tools_raw = isset( $extra['command_tools'] ) && is_array( $extra['command_tools'] ) ? $extra['command_tools'] : array();
672 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
673 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
674 ? (string) $extra['system_prompt_mode']
675 : 'append';
676
677 /**
678 * Fires once per `/ai/search` invocation, after validation and
679 * before any the provider call. First anchor in the observability trio
680 * (`desktop_mode_ai_search_started` / `desktop_mode_ai_tool_called`
681 * / `desktop_mode_ai_search_completed`).
682 *
683 * @since 0.5.1
684 *
685 * @param array $context {
686 * @type string $query User query.
687 * @type int $user_id
688 * @type string $request_id UUID correlating the whole run.
689 * }
690 */
691 do_action(
692 'desktop_mode_ai_search_started',
693 array(
694 'query' => $query,
695 'user_id' => $user_id,
696 'request_id' => $request_id,
697 )
698 );
699
700 if ( $initial_tool !== null && ! in_array( $initial_tool, desktop_mode_ai_search_resumable_tools(), true ) ) {
701 $initial_tool = null;
702 }
703
704 // When resuming a previous exhausted run, prime the model with the
705 // starting position so it doesn't waste iterations on already-searched
706 // content.
707 $continuation_note = '';
708 if ( $initial_tool !== null && ( $start_offset > 0 || $initial_tool !== 'search_posts' ) ) {
709 $continuation_note = sprintf(
710 "\n\nNote: This is a continuation of a previous search. Begin with %s using the same search keywords at offset=%d and work forward.",
711 $initial_tool,
712 $start_offset
713 );
714 }
715
716 $instructions = "
717 You are a friendly, conversational assistant embedded in a WordPress site. You help the site owner:
718
719 1. **Find content** they've written (posts, pages, comments) by describing it in natural language.
720 2. **Navigate wp-admin** when they ask where to find something (\"where are the categories?\", \"how do I manage users?\").
721 3. **Recommend plugins** from the official WordPress.org directory when they need extra functionality.
722 4. **Check the site's error log** when they're troubleshooting something.
723 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.
724 6. **Chat** — only when no tool fits, answer conversationally.
725
726 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.
727
728 Tools (your actual tool list may include more than these — use any that fit the request):
729 - 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.
730 - list_admin_pages: returns the full catalog of wp-admin destinations. Call once per navigation query, then select the 1-3 most relevant entries.
731 - 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�
732 \" (rating is 0-100, divide by 20 to get stars).
733 - 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.
734
735 Choosing which track:
736 - \"I remember a post/page/comment about X\" → the corresponding search_* tool.
737 - \"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.
738 - \"plugin for X\" / \"recommend a plugin\" → search_wporg_plugins → present as admin_links.
739 - \"any errors?\" / \"check logs\" / troubleshooting → get_php_error_log → summarise in chat.
740 - 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\".
741 - Greeting, unclear, or chit-chat → answer_type \"chat\" with a brief helpful message (no tools needed).
742
743 Always return one of three answer_type values in the structured output:
744 - \"entity\": you identified a single post/page/comment. Fill entity_id + entity_type. admin_links = null.
745 - \"navigation\": you're recommending admin pages OR plugin install links. Fill admin_links. entity_id + entity_type = null.
746 - \"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.
747
748 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.
749 ";
750
751 if ( $continuation_note ) {
752 $instructions .= $continuation_note;
753 }
754
755 // -----------------------------------------------------------------------
756 // System-prompt extensibility. All three layers — appendix filter,
757 // client override (append/replace with capability gate), and final
758 // transform — live in `desktop_mode_ai_compose_instructions()` so the
759 // primary run and the follow-up leg stay in lockstep. See the
760 // helper for the order of application; the filter docblocks at its
761 // `apply_filters()` call sites carry the public contract on each
762 // extension point.
763 // -----------------------------------------------------------------------
764 $prompt_context = array(
765 'query' => $query,
766 'user_id' => $user_id,
767 'request_id' => $request_id,
768 );
769
770 $instructions = desktop_mode_ai_compose_instructions(
771 $instructions,
772 $prompt_context,
773 array( 'text' => $system_prompt_text, 'mode' => $system_prompt_mode )
774 );
775
776 // -----------------------------------------------------------------------
777 // Tool assembly — built-in search/navigation abilities + client-supplied
778 // command tools.
779 //
780 // Built-in tools are WordPress Abilities API abilities. Each is advertised
781 // to the model as a function declaration named after the ability (namespace
782 // stripped), and $ability_by_tool maps that name back to the ability so the
783 // agent loop can resolve + execute() it (permission + input/output
784 // validation happen inside execute()).
785 // -----------------------------------------------------------------------
786 $ability_by_tool = array();
787 $builtin_tools = array();
788
789 foreach ( desktop_mode_ai_search_ability_names() as $ability_name ) {
790 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
791 if ( ! $ability instanceof WP_Ability ) {
792 continue;
793 }
794
795 $tool_name = desktop_mode_ai_ability_tool_name( $ability_name );
796 $ability_by_tool[ $tool_name ] = $ability_name;
797 $valid_tools[] = $tool_name;
798
799 $input_schema = $ability->get_input_schema();
800 $builtin_tools[] = array(
801 'type' => 'function',
802 'name' => $tool_name,
803 'description' => (string) $ability->get_description(),
804 'parameters' => ! empty( $input_schema )
805 ? $input_schema
806 : array( 'type' => 'object', 'properties' => (object) array() ),
807 );
808 }
809
810 // Command tools — namespaced as `command_<slug>` on the server so
811 // they can't collide with built-in tool names. Each takes a single
812 // optional `args` string arg (matches the slash-command contract
813 // where args are a single string the plugin's `run()` parses).
814 $command_tools_by_name = array();
815 $command_defs = array();
816
817 foreach ( $command_tools_raw as $cmd ) {
818 if ( ! is_array( $cmd ) ) {
819 continue;
820 }
821 $slug = isset( $cmd['slug'] ) ? (string) $cmd['slug'] : '';
822 if ( $slug === '' || ! preg_match( '/^[a-z0-9_\-]+$/', $slug ) ) {
823 continue;
824 }
825 /**
826 * Per-tool filter on the client-supplied command list. Return
827 * `false` to drop a command entirely before it reaches the
828 * model — the right hook for per-role / per-command gating.
829 *
830 * @since 0.5.1
831 *
832 * @param bool|array $allowed Either the (possibly mutated) command
833 * tool entry, or `false` to drop it.
834 * @param string $slug Command slug.
835 * @param array $context { user_id, request_id }.
836 */
837 $allowed = apply_filters(
838 'desktop_mode_ai_command_allowed',
839 $cmd,
840 $slug,
841 array( 'user_id' => $user_id, 'request_id' => $request_id )
842 );
843 if ( false === $allowed || ! is_array( $allowed ) ) {
844 continue;
845 }
846 $label = isset( $allowed['label'] ) ? (string) $allowed['label'] : $slug;
847 $description = isset( $allowed['description'] ) ? (string) $allowed['description'] : '';
848 $hint = isset( $allowed['hint'] ) ? (string) $allowed['hint'] : '';
849 $tool_name = 'command_' . $slug;
850
851 $command_tools_by_name[ $tool_name ] = array( 'slug' => $slug );
852 $command_defs[] = array(
853 'type' => 'function',
854 'name' => $tool_name,
855 'description' => trim( $label . ( '' !== $description ? '' . $description : '' ) ),
856 'parameters' => array(
857 'type' => 'object',
858 'properties' => array(
859 'args' => array(
860 'type' => 'string',
861 'description' => $hint !== ''
862 ? sprintf( 'Arguments for this command. Hint: %s', $hint )
863 : 'Arguments for this command. Leave empty when the command takes none.',
864 ),
865 ),
866 'required' => array( 'args' ),
867 'additionalProperties' => false,
868 ),
869 );
870 }
871
872 /**
873 * Transform the command-tool subset before merging with the
874 * built-in + registered tools. Useful for bulk gating, renaming,
875 * or injecting synthetic command tools.
876 *
877 * @since 0.5.1
878 *
879 * @param array $command_defs Command tool definitions.
880 * @param array $context { user_id, request_id }.
881 */
882 $command_defs = (array) apply_filters(
883 'desktop_mode_ai_command_tools',
884 $command_defs,
885 array( 'user_id' => $user_id, 'request_id' => $request_id )
886 );
887
888 $tools = array_merge( $builtin_tools, $command_defs );
889
890 /**
891 * Transform the full tool list (built-in abilities + command tools) just
892 * before it goes to the provider. Fires once per run — changes apply to
893 * every iteration in the agent loop.
894 *
895 * @since 0.5.1
896 *
897 * @param array $tools Full the provider tool definitions array.
898 * @param array $context { user_id, request_id, query }.
899 */
900 $tools = (array) apply_filters(
901 'desktop_mode_ai_tools',
902 $tools,
903 array( 'user_id' => $user_id, 'request_id' => $request_id, 'query' => $query )
904 );
905
906 // Normalize every tool's schema onto the provider-supported subset, AFTER the
907 // filter so it covers the complete list the provider will receive — built-in
908 // abilities, command tools, and anything a plugin injected. One tool with a
909 // legal-but-unsupported schema otherwise 400s the entire request, not just its
910 // own tool. Only the model-facing copy is reshaped; abilities still validate
911 // arguments against their real schema in execute(). Idempotent, so a plugin
912 // that already normalizes on the filter above is unaffected.
913 foreach ( $tools as $ti => $tool ) {
914 if ( is_array( $tool ) && isset( $tool['parameters'] ) ) {
915 $tools[ $ti ]['parameters'] = desktop_mode_ai_normalize_tool_schema( $tool['parameters'] );
916 }
917 }
918
919 // Widen the permitted-tools list with the command tools — the agent loop
920 // rejects any `function_call` whose name isn't in here (built-in ability
921 // names were added above).
922 foreach ( $command_defs as $def ) {
923 if ( isset( $def['name'] ) ) {
924 $valid_tools[] = (string) $def['name'];
925 }
926 }
927
928 $answer_schema = desktop_mode_ai_search_answer_schema();
929
930 $emit( array( 'phase' => 'start', 'message' => 'Thinking about your question…' ) );
931
932 // -----------------------------------------------------------------------
933 // First call — user query as the sole message, instructions as system
934 // guidance. Generation routes through the WordPress AI Client; the tools
935 // are advertised as function declarations and dispatched by this loop.
936 // The full ordered conversation is rebuilt and re-sent each turn.
937 // -----------------------------------------------------------------------
938 $messages = array( desktop_mode_ai_user_text_message( $query ) );
939
940 $turn = desktop_mode_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
941
942 if ( is_wp_error( $turn ) ) {
943 return $turn;
944 }
945
946 $last_tool = $initial_tool ?? 'search_posts';
947 $last_offset = $start_offset;
948 $last_has_more = true;
949 $iterations = 0;
950
951 // Accumulate token usage across every turn and remember the last model the
952 // AI Client resolved, for the `desktop_mode_ai_search_completed` payload.
953 $total_usage = array( 'prompt' => 0, 'completion' => 0, 'total' => 0 );
954 $last_model = null;
955 $accrue_usage = static function ( $turn ) use ( &$total_usage, &$last_model ) {
956 if ( ! is_array( $turn ) ) {
957 return;
958 }
959 if ( isset( $turn['usage'] ) && is_array( $turn['usage'] ) ) {
960 $total_usage['prompt'] += (int) ( $turn['usage']['prompt'] ?? 0 );
961 $total_usage['completion'] += (int) ( $turn['usage']['completion'] ?? 0 );
962 $total_usage['total'] += (int) ( $turn['usage']['total'] ?? 0 );
963 }
964 if ( isset( $turn['model'] ) && is_array( $turn['model'] ) ) {
965 $last_model = $turn['model'];
966 }
967 };
968 $accrue_usage( $turn );
969
970 // -----------------------------------------------------------------------
971 // Agentic loop — each iteration either executes tool calls or returns
972 // the final answer. The full ordered conversation (user query, assistant
973 // turns, tool results) is accumulated in $messages and re-sent each turn.
974 // -----------------------------------------------------------------------
975 for ( $i = 0; $i < DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS; $i++ ) {
976 $function_calls = is_array( $turn['function_calls'] ?? null ) ? $turn['function_calls'] : array();
977
978 // No tool calls in this response → final answer.
979 if ( empty( $function_calls ) ) {
980 $emit( array( 'phase' => 'composing', 'message' => 'Putting together your answer…' ) );
981 $text = $turn['text'] ?? null;
982 if ( ! is_string( $text ) ) {
983 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
984 error_log( '[WP Desktop Mode AI] AI Client returned no text in the final turn.' );
985 return new WP_Error(
986 'desktop_mode_ai_empty',
987 'The AI provider returned no text in the final turn.'
988 );
989 }
990
991 $answer = json_decode( $text, true );
992 if ( ! is_array( $answer ) ) {
993 return new WP_Error( 'desktop_mode_ai_result_parse', 'Could not parse structured search answer.' );
994 }
995
996 $answer_type = isset( $answer['answer_type'] ) && in_array( $answer['answer_type'], array( 'entity', 'navigation', 'chat' ), true )
997 ? (string) $answer['answer_type']
998 : 'chat';
999 $message = isset( $answer['message'] ) ? (string) $answer['message'] : '';
1000 $entity_id = ( isset( $answer['entity_id'] ) && is_int( $answer['entity_id'] ) )
1001 ? $answer['entity_id'] : null;
1002 $entity_type = ( isset( $answer['entity_type'] ) && is_string( $answer['entity_type'] ) )
1003 ? $answer['entity_type'] : null;
1004 $admin_links = isset( $answer['admin_links'] ) && is_array( $answer['admin_links'] )
1005 ? $answer['admin_links'] : null;
1006
1007 $entity = null;
1008 if ( 'entity' === $answer_type && $entity_id && $entity_type ) {
1009 $entity = desktop_mode_ai_search_build_entity( $entity_type, $entity_id );
1010 }
1011
1012 $final = array(
1013 'answer_type' => $answer_type,
1014 'message' => $message,
1015 'entity' => $entity,
1016 'admin_links' => $admin_links,
1017 'iterations' => $iterations + 1,
1018 'exhausted' => ! $last_has_more,
1019 'continue' => null,
1020 'request_id' => $request_id,
1021 );
1022
1023 /**
1024 * Final transform hook — fires right before the HTTP
1025 * response is returned. Plugins can rewrite `message`,
1026 * inject `admin_links`, coerce `answer_type`, etc.
1027 *
1028 * @since 0.5.1
1029 *
1030 * @param array $answer Final answer payload.
1031 * @param array $context { query, user_id, request_id }.
1032 */
1033 $final = (array) apply_filters(
1034 'desktop_mode_ai_answer',
1035 $final,
1036 array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1037 );
1038
1039 do_action(
1040 'desktop_mode_ai_search_completed',
1041 array(
1042 'query' => $query,
1043 'user_id' => $user_id,
1044 'request_id' => $request_id,
1045 'answer_type' => $final['answer_type'] ?? 'chat',
1046 'iterations' => $final['iterations'] ?? 0,
1047 'usage' => $total_usage,
1048 'model' => $last_model,
1049 )
1050 );
1051
1052 return $final;
1053 }
1054
1055 // -------------------------------------------------------------------
1056 // Command-tool short-circuit.
1057 //
1058 // If the model emitted `command_<slug>`, we return immediately with
1059 // `answer_type: 'tool_call'` — the client owns the command's `run()`
1060 // function (lives in plugin JS) and executes it locally. We do NOT
1061 // send anything else back to the provider this turn — would burn tokens
1062 // for a no-op second response.
1063 // -------------------------------------------------------------------
1064 $command_tool_call = null;
1065 foreach ( $function_calls as $fc ) {
1066 $name = (string) ( $fc['name'] ?? '' );
1067 if ( isset( $command_tools_by_name[ $name ] ) ) {
1068 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1069 $decoded = is_array( $raw ) ? $raw : array();
1070 $command_tool_call = array(
1071 'slug' => $command_tools_by_name[ $name ]['slug'],
1072 'args' => isset( $decoded['args'] ) ? (string) $decoded['args'] : '',
1073 );
1074 break;
1075 }
1076 }
1077 if ( $command_tool_call !== null ) {
1078 do_action(
1079 'desktop_mode_ai_tool_called',
1080 array(
1081 'tool_name' => 'command_' . $command_tool_call['slug'],
1082 'args' => array( 'args' => $command_tool_call['args'] ),
1083 'user_id' => $user_id,
1084 'request_id' => $request_id,
1085 )
1086 );
1087
1088 $final = array(
1089 'answer_type' => 'tool_call',
1090 'message' => '',
1091 'entity' => null,
1092 'admin_links' => null,
1093 'tool' => $command_tool_call,
1094 'iterations' => $iterations + 1,
1095 'exhausted' => false,
1096 'continue' => null,
1097 'request_id' => $request_id,
1098 );
1099
1100 $final = (array) apply_filters(
1101 'desktop_mode_ai_answer',
1102 $final,
1103 array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1104 );
1105
1106 do_action(
1107 'desktop_mode_ai_search_completed',
1108 array(
1109 'query' => $query,
1110 'user_id' => $user_id,
1111 'request_id' => $request_id,
1112 'answer_type' => 'tool_call',
1113 'iterations' => $final['iterations'] ?? 0,
1114 'usage' => $total_usage,
1115 'model' => $last_model,
1116 )
1117 );
1118
1119 return $final;
1120 }
1121
1122 // Execute each tool call and collect results as
1123 // `{ call_id, name, response }` — turned into FunctionResponse parts
1124 // for the next turn by desktop_mode_ai_tool_result_message().
1125 $tool_outputs = array();
1126 foreach ( $function_calls as $fc ) {
1127 $tool_name = $fc['name'] ?? '';
1128 $call_id = $fc['call_id'] ?? '';
1129
1130 if ( ! in_array( $tool_name, $valid_tools, true ) ) {
1131 $tool_outputs[] = array(
1132 'call_id' => $call_id,
1133 'name' => $tool_name,
1134 'response' => array( 'error' => "Unknown tool '{$tool_name}'." ),
1135 );
1136 continue;
1137 }
1138
1139 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1140 $args = is_array( $raw ) ? $raw : array();
1141 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
1142
1143 $emit( array(
1144 'phase' => 'tool_call',
1145 'tool' => $tool_name,
1146 'message' => desktop_mode_ai_progress_message( $tool_name ),
1147 ) );
1148
1149 do_action(
1150 'desktop_mode_ai_tool_called',
1151 array(
1152 'tool_name' => $tool_name,
1153 'args' => $args,
1154 'user_id' => $user_id,
1155 'request_id' => $request_id,
1156 )
1157 );
1158
1159 // Resolve + run the ability. execute() runs the permission_callback
1160 // and validates input/output; a denial or bad input comes back as a
1161 // WP_Error, which we surface to the model as a clean tool error
1162 // (never a fatal) and report on the observability channel.
1163 $ability = isset( $ability_by_tool[ $tool_name ] ) ? wp_get_ability( $ability_by_tool[ $tool_name ] ) : null;
1164 if ( $ability instanceof WP_Ability ) {
1165 // Abilities that declare no input schema (e.g. Core's
1166 // get-*-info) reject any non-null input, so pass null when
1167 // there's no schema; otherwise hand over the decoded args.
1168 $input = empty( $ability->get_input_schema() ) ? null : $args;
1169 $result = $ability->execute( $input );
1170 } else {
1171 $result = new WP_Error( 'desktop_mode_ai_unknown_ability', sprintf( 'Ability for tool "%s" is unavailable.', $tool_name ) );
1172 }
1173
1174 if ( is_wp_error( $result ) ) {
1175 do_action(
1176 'desktop_mode_ai_search_error',
1177 array(
1178 'stage' => 'tool_execute',
1179 'tool_name' => $tool_name,
1180 'error' => $result->get_error_code(),
1181 'message' => $result->get_error_message(),
1182 'user_id' => $user_id,
1183 'request_id' => $request_id,
1184 )
1185 );
1186 $batch = array(
1187 'error' => $result->get_error_message(),
1188 'error_code' => $result->get_error_code(),
1189 );
1190 } else {
1191 $batch = is_array( $result ) ? $result : array( 'result' => $result );
1192 }
1193
1194 $last_tool = $tool_name;
1195 $last_offset = $offset;
1196 $last_has_more = (bool) ( $batch['has_more'] ?? false );
1197
1198 /**
1199 * Transform a tool result before it goes back to the model.
1200 * Fires for every ability-dispatched tool (including error
1201 * envelopes from a failed execute()).
1202 *
1203 * @since 0.5.1
1204 *
1205 * @param array $batch Tool result payload.
1206 * @param string $tool_name Tool function name.
1207 * @param array $args Decoded args from the call.
1208 * @param array $context { user_id, request_id }.
1209 */
1210 $batch = (array) apply_filters(
1211 'desktop_mode_ai_tool_result',
1212 $batch,
1213 $tool_name,
1214 $args,
1215 array( 'user_id' => $user_id, 'request_id' => $request_id )
1216 );
1217
1218 $tool_outputs[] = array(
1219 'call_id' => $call_id,
1220 'name' => $tool_name,
1221 'response' => $batch,
1222 );
1223 }
1224
1225 $iterations++;
1226
1227 // Next turn — append the assistant's tool-call turn and our tool
1228 // results to the conversation, then regenerate with the full history.
1229 $messages[] = $turn['message'];
1230 $messages[] = desktop_mode_ai_tool_result_message( $tool_outputs );
1231
1232 $turn = desktop_mode_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
1233
1234 if ( is_wp_error( $turn ) ) {
1235 return $turn;
1236 }
1237 $accrue_usage( $turn );
1238 }
1239
1240 // -----------------------------------------------------------------------
1241 // Budget exhausted before a final answer.
1242 // -----------------------------------------------------------------------
1243 $continue = null;
1244 if ( $last_has_more ) {
1245 $next_offset = $last_offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE;
1246 // `search_comments_by_post` cannot resume — the continue payload
1247 // carries no post_id — so fall back to plain comment search,
1248 // keeping `tool` inside desktop_mode_ai_search_resumable_tools().
1249 $resume_tool = 'search_comments_by_post' === $last_tool ? 'search_comments' : $last_tool;
1250 $type_label = str_replace( 'search_', '', $resume_tool ) . 's';
1251 $continue = array(
1252 'tool' => $resume_tool,
1253 'entity_type' => rtrim( str_replace( 'search_', '', $resume_tool ), 's' ),
1254 'offset' => $next_offset,
1255 'label' => sprintf( 'Continue searching in %s (from item %d)', $type_label, $next_offset + 1 ),
1256 );
1257 }
1258
1259 $final = array(
1260 'answer_type' => 'chat',
1261 'message' => 'I searched 100 items without finding a clear match. Want me to keep looking further?',
1262 'entity' => null,
1263 'admin_links' => null,
1264 'iterations' => DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS,
1265 'exhausted' => ! $last_has_more,
1266 'continue' => $continue,
1267 'request_id' => $request_id,
1268 );
1269
1270 $final = (array) apply_filters(
1271 'desktop_mode_ai_answer',
1272 $final,
1273 array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1274 );
1275
1276 do_action(
1277 'desktop_mode_ai_search_completed',
1278 array(
1279 'query' => $query,
1280 'user_id' => $user_id,
1281 'request_id' => $request_id,
1282 'answer_type' => 'chat',
1283 'iterations' => DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS,
1284 'usage' => $total_usage,
1285 'model' => $last_model,
1286 )
1287 );
1288
1289 return $final;
1290 }
1291
1292 /**
1293 * Compose the final system-prompt string for an `/ai/search` call.
1294 *
1295 * One code path used by both the primary run and the follow-up leg —
1296 * keeps the voice consistent across legs and removes a class of drift
1297 * bug where the two paths's prompt-assembly drifts apart.
1298 *
1299 * Applies three layers in order:
1300 * 1. `desktop_mode_ai_system_prompt_appendix` — stacking filter;
1301 * every plugin's return is concatenated.
1302 * 2. Client override — `system_prompt_text` + `system_prompt_mode`.
1303 * `append` always allowed; `replace` gated on
1304 * `desktop_mode_ai_system_prompt_replace_capability`. Non-permitted
1305 * `replace` downgrades to `append` so the caller's text is
1306 * preserved rather than dropped.
1307 * 3. `desktop_mode_ai_system_prompt` — final transform pass.
1308 *
1309 * @since 0.5.1
1310 * @internal
1311 *
1312 * @param string $core Built-in instructions for this phase
1313 * (agent loop / follow-up summariser).
1314 * @param array $context { query, user_id, request_id, phase? }.
1315 * @param array $client { text, mode } client override; either field
1316 * empty means no override.
1317 * @return string Composed system prompt.
1318 */
1319 function desktop_mode_ai_compose_instructions( $core, array $context, array $client = array() ) {
1320 $instructions = (string) $core;
1321 $user_id = isset( $context['user_id'] ) ? (int) $context['user_id'] : 0;
1322
1323 $client_text = isset( $client['text'] ) && is_string( $client['text'] ) ? $client['text'] : '';
1324 $client_mode = isset( $client['mode'] ) && in_array( $client['mode'], array( 'append', 'replace' ), true )
1325 ? (string) $client['mode']
1326 : 'append';
1327
1328 $ctx_for_filter = $context;
1329 $ctx_for_filter['client_override'] = '' !== $client_text ? $client_mode : null;
1330
1331 /**
1332 * Short-circuit extension — appended to the built-in instructions
1333 * verbatim. Use this when a plugin just wants to add domain
1334 * context (room list, product catalogue, company jargon) without
1335 * restructuring the core rules. Fires for both the primary
1336 * `/ai/search` run and the follow-up composed-reply leg.
1337 *
1338 * @since 0.5.1
1339 *
1340 * @param string $appendix Accumulated appendix. Default empty.
1341 * @param array $context { query, user_id, request_id, client_override, phase? }.
1342 */
1343 $server_appendix = (string) apply_filters( 'desktop_mode_ai_system_prompt_appendix', '', $ctx_for_filter );
1344 if ( '' !== $server_appendix ) {
1345 $instructions .= "\n\n" . $server_appendix;
1346 }
1347
1348 if ( '' !== $client_text ) {
1349 if ( 'replace' === $client_mode ) {
1350 /**
1351 * Capability required for a client to send
1352 * `system_prompt: { mode: 'replace' }`. Defaults to `manage_options`
1353 * — replacing the whole prompt can effectively hijack the
1354 * assistant, so it's admin-only out of the box.
1355 *
1356 * @since 0.5.1
1357 *
1358 * @param string $capability Default `manage_options`.
1359 * @param array $context
1360 */
1361 $required_cap = (string) apply_filters(
1362 'desktop_mode_ai_system_prompt_replace_capability',
1363 'manage_options',
1364 $ctx_for_filter
1365 );
1366 if ( '' === $required_cap || ( $user_id > 0 && user_can( $user_id, $required_cap ) ) ) {
1367 $instructions = $client_text;
1368 } else {
1369 // Silently downgrade to append — preserves the caller's
1370 // text rather than dropping it when the cap check fails.
1371 $instructions .= "\n\n" . $client_text;
1372 }
1373 } else {
1374 $instructions .= "\n\n" . $client_text;
1375 }
1376 }
1377
1378 /**
1379 * Final transform pass. Fires after the built-in instructions,
1380 * server appendix, and client override have all been composed.
1381 *
1382 * @since 0.5.1
1383 *
1384 * @param string $instructions Composed system prompt.
1385 * @param array $context
1386 */
1387 return (string) apply_filters( 'desktop_mode_ai_system_prompt', $instructions, $ctx_for_filter );
1388 }
1389
1390 /**
1391 * Compose a natural-language reply describing the outcome of a
1392 * client-dispatched command invocation.
1393 *
1394 * Called by the REST endpoint when the client sends `follow_up` —
1395 * the second leg of the opt-in agentic flow triggered by
1396 * `wp.desktop.ai.ask( q, { tools: 'aiCallable', followUp: true } )`.
1397 *
1398 * Single-turn, no tools, no structured-output schema — the model
1399 * sees the original query + a summary of what happened and writes a
1400 * one/two-sentence reply in the voice of the system prompt. We reuse
1401 * the same system-prompt pipeline as the main search so plugins
1402 * appending instructions via `desktop_mode_ai_system_prompt_appendix`
1403 * see consistent voice across the two legs.
1404 *
1405 * @since 0.5.1
1406 *
1407 * @param string $query Original user query.
1408 * @param array $tool { slug, args } — what ran.
1409 * @param array $outcome Tool result payload. Opaque — JSON-encoded
1410 * into the model's context so it can reason
1411 * about whatever shape the plugin returned.
1412 * @param array $extra Same shape as `desktop_mode_ai_run_search`'s
1413 * `$extra` — carries user_id, request_id,
1414 * system-prompt overrides.
1415 * @return array|WP_Error `{ answer_type: 'chat', message, … }` or error.
1416 */
1417 function desktop_mode_ai_run_followup( $query, array $tool, array $outcome, array $extra = array() ) {
1418 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
1419 $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && $extra['request_id'] !== ''
1420 ? (string) $extra['request_id']
1421 : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ) );
1422
1423 do_action(
1424 'desktop_mode_ai_search_started',
1425 array(
1426 'query' => $query,
1427 'user_id' => $user_id,
1428 'request_id' => $request_id,
1429 'phase' => 'follow_up',
1430 )
1431 );
1432
1433 // Mirror the main search's system-prompt layering so voice stays
1434 // consistent between the two legs. We build a simpler core-
1435 // instructions block — no tool guidance, since this run has none.
1436 $instructions = "
1437 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.
1438
1439 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.
1440
1441 Rules:
1442 - If the outcome looks successful, confirm plainly. Example: \"Done — your office light is on now.\"
1443 - 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.
1444 - Do NOT recommend the user try something else unless the outcome explicitly suggests it.
1445 - Do NOT describe the tool mechanism (\"I called command_turn_light\") — the user only cares about the real-world effect.
1446 ";
1447
1448 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
1449 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1450 ? (string) $extra['system_prompt_mode']
1451 : 'append';
1452
1453 $instructions = desktop_mode_ai_compose_instructions(
1454 $instructions,
1455 array(
1456 'query' => $query,
1457 'user_id' => $user_id,
1458 'request_id' => $request_id,
1459 'phase' => 'follow_up',
1460 ),
1461 array( 'text' => $system_prompt_text, 'mode' => $system_prompt_mode )
1462 );
1463
1464 $slug = isset( $tool['slug'] ) ? (string) $tool['slug'] : '';
1465 $tool_args = isset( $tool['args'] ) ? (string) $tool['args'] : '';
1466 $outcome_json = wp_json_encode( $outcome );
1467 if ( ! is_string( $outcome_json ) ) {
1468 $outcome_json = '""';
1469 }
1470
1471 // Bound the outcome payload so a malicious or buggy plugin that
1472 // returns a 5MB blob can't inflate the provider token usage without
1473 // bound. 4 KB is enough for a status string, a small result list,
1474 // or a short error envelope — anything bigger gets truncated with
1475 // a marker so the model knows the tail was dropped.
1476 //
1477 // `mb_*` variants so truncation on a multibyte boundary
1478 // (Japanese / emoji / accented UTF-8) can't produce invalid JSON
1479 // that the provider would reject. Falls back to byte-level substr when
1480 // mbstring is unavailable (rare but possible on minimal PHP
1481 // builds).
1482 $max_outcome_len = (int) apply_filters( 'desktop_mode_ai_followup_outcome_max_chars', 4000 );
1483 if ( $max_outcome_len > 0 ) {
1484 $has_mbstring = function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' );
1485 $current_len = $has_mbstring
1486 ? mb_strlen( $outcome_json, 'UTF-8' )
1487 : strlen( $outcome_json );
1488 if ( $current_len > $max_outcome_len ) {
1489 $outcome_json = $has_mbstring
1490 ? mb_substr( $outcome_json, 0, $max_outcome_len, 'UTF-8' )
1491 : substr( $outcome_json, 0, $max_outcome_len );
1492 $outcome_json .= '…[truncated]';
1493 }
1494 }
1495
1496 $user_message = sprintf(
1497 "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.",
1498 $query,
1499 $slug,
1500 $tool_args,
1501 $outcome_json
1502 );
1503
1504 do_action(
1505 'desktop_mode_ai_tool_called',
1506 array(
1507 'tool_name' => 'followup_summarise',
1508 'args' => array( 'slug' => $slug, 'tool_args' => $tool_args ),
1509 'user_id' => $user_id,
1510 'request_id' => $request_id,
1511 )
1512 );
1513
1514 $turn = desktop_mode_ai_client_generate(
1515 $user_id,
1516 array( desktop_mode_ai_user_text_message( $user_message ) ),
1517 array(), // no tools — we want a plain reply
1518 null, // no JSON schema — free-form text
1519 $instructions
1520 );
1521
1522 if ( is_wp_error( $turn ) ) {
1523 do_action(
1524 'desktop_mode_ai_search_error',
1525 array(
1526 'code' => $turn->get_error_code(),
1527 'message' => $turn->get_error_message(),
1528 'data' => $turn->get_error_data(),
1529 'user_id' => $user_id,
1530 'request_id' => $request_id,
1531 'phase' => 'follow_up',
1532 )
1533 );
1534 return $turn;
1535 }
1536
1537 $text = $turn['text'] ?? null;
1538 $fallback = false;
1539 if ( ! is_string( $text ) || '' === trim( $text ) ) {
1540 // Graceful degrade — if the provider returned nothing usable, fall
1541 // back to a generic confirmation so the caller always has a
1542 // message to show. Better than returning an error and losing
1543 // the fact that the command *did* run. We flag the degrade so
1544 // observability subscribers can distinguish a deliberate
1545 // "Done." from a silently-degraded one.
1546 $text = 'Done.';
1547 $fallback = true;
1548 }
1549
1550 $final = array(
1551 'answer_type' => 'chat',
1552 'message' => trim( $text ),
1553 'entity' => null,
1554 'admin_links' => null,
1555 'iterations' => 1,
1556 'exhausted' => false,
1557 'continue' => null,
1558 'request_id' => $request_id,
1559 'tool' => array( 'slug' => $slug, 'args' => $tool_args ),
1560 'fallback' => $fallback,
1561 );
1562
1563 $final = (array) apply_filters(
1564 'desktop_mode_ai_answer',
1565 $final,
1566 array(
1567 'query' => $query,
1568 'user_id' => $user_id,
1569 'request_id' => $request_id,
1570 'phase' => 'follow_up',
1571 )
1572 );
1573
1574 do_action(
1575 'desktop_mode_ai_search_completed',
1576 array(
1577 'query' => $query,
1578 'user_id' => $user_id,
1579 'request_id' => $request_id,
1580 'answer_type' => 'chat',
1581 'iterations' => 1,
1582 'phase' => 'follow_up',
1583 'fallback' => $fallback,
1584 )
1585 );
1586
1587 return $final;
1588 }
1589
1590 // ---------------------------------------------------------------------------
1591 // REST endpoint
1592 // ---------------------------------------------------------------------------
1593
1594 /**
1595 * Registers the AI search REST route.
1596 *
1597 * @since 0.5.0
1598 */
1599 function desktop_mode_register_ai_search_rest_route() {
1600 register_rest_route(
1601 'desktop-mode/v1',
1602 '/ai/search',
1603 array(
1604 'methods' => WP_REST_Server::CREATABLE,
1605 'callback' => 'desktop_mode_rest_ai_search',
1606 'permission_callback' => 'desktop_mode_rest_ai_search_permission',
1607 'args' => array(
1608 'query' => array(
1609 'required' => true,
1610 'type' => 'string',
1611 'sanitize_callback' => 'sanitize_text_field',
1612 'validate_callback' => static function ( $v ) {
1613 return is_string( $v ) && trim( $v ) !== '';
1614 },
1615 ),
1616 // `resume_tool` + `start_offset` are only set when the
1617 // client is continuing a previous search from the `continue`
1618 // object returned by an exhausted run. Fresh searches leave
1619 // both unset — the agent picks tools from query semantics.
1620 'resume_tool' => array(
1621 'required' => false,
1622 'type' => array( 'string', 'null' ),
1623 'default' => null,
1624 'sanitize_callback' => static function ( $v ) {
1625 return in_array( $v, desktop_mode_ai_search_resumable_tools(), true )
1626 ? $v : null;
1627 },
1628 ),
1629 'start_offset' => array(
1630 'required' => false,
1631 'type' => 'integer',
1632 'default' => 0,
1633 'sanitize_callback' => 'absint',
1634 ),
1635 // Client-harvested slash-commands the user's plugins have
1636 // opted in as AI tools. Each entry: { slug, label, description?, hint? }.
1637 // The slug is namespaced server-side as `command_<slug>`
1638 // and any tool_call the model emits with that name short-
1639 // circuits back to the client for local dispatch.
1640 'command_tools' => array(
1641 'required' => false,
1642 'type' => 'array',
1643 'default' => array(),
1644 'items' => array(
1645 'type' => 'object',
1646 'properties' => array(
1647 'slug' => array( 'type' => 'string' ),
1648 'label' => array( 'type' => 'string' ),
1649 'description' => array( 'type' => 'string' ),
1650 'hint' => array( 'type' => 'string' ),
1651 ),
1652 ),
1653 ),
1654 // Free-form system-prompt override.
1655 // mode: 'append' → concatenated onto the built-in prompt (safe for everyone)
1656 // mode: 'replace' → replaces the built-in prompt entirely, gated on
1657 // `desktop_mode_ai_system_prompt_replace_capability`
1658 // (default `manage_options`).
1659 'system_prompt_text' => array(
1660 'required' => false,
1661 'type' => 'string',
1662 'default' => '',
1663 ),
1664 'system_prompt_mode' => array(
1665 'required' => false,
1666 'type' => 'string',
1667 'default' => 'append',
1668 'sanitize_callback' => static function ( $v ) {
1669 return in_array( $v, array( 'append', 'replace' ), true ) ? $v : 'append';
1670 },
1671 ),
1672 // Follow-up leg of the agentic command-dispatch flow.
1673 // When present, the endpoint SKIPS the agent loop entirely
1674 // and runs a single-turn "summarise this outcome" call
1675 // through the provider instead. The client sends this on the
1676 // second leg of `ask( q, { tools: 'aiCallable', followUp: true } )`.
1677 'follow_up' => array(
1678 'required' => false,
1679 'type' => array( 'object', 'null' ),
1680 'default' => null,
1681 ),
1682 ),
1683 )
1684 );
1685 }
1686 add_action( 'rest_api_init', 'desktop_mode_register_ai_search_rest_route' );
1687
1688 /**
1689 * Permission callback.
1690 *
1691 * @since 0.5.0
1692 *
1693 * @return bool|WP_Error
1694 */
1695 function desktop_mode_rest_ai_search_permission() {
1696 if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
1697 return new WP_Error(
1698 'desktop_mode_ai_forbidden',
1699 'You must be logged in to use the AI assistant.',
1700 array( 'status' => 403 )
1701 );
1702 }
1703 if ( ! desktop_mode_ai_is_available() ) {
1704 return new WP_Error(
1705 'desktop_mode_ai_unavailable',
1706 'The AI assistant is unavailable on this site.',
1707 array( 'status' => 503 )
1708 );
1709 }
1710 if ( ! desktop_mode_ai_is_enabled( get_current_user_id() ) ) {
1711 return new WP_Error(
1712 'desktop_mode_ai_disabled',
1713 'The AI assistant is turned off. Enable it in OS Settings → Features.',
1714 array( 'status' => 403 )
1715 );
1716 }
1717 return true;
1718 }
1719
1720 /**
1721 * POST /desktop-mode/v1/ai/search
1722 *
1723 * @since 0.5.0
1724 *
1725 * @param WP_REST_Request $request
1726 * @return WP_REST_Response|WP_Error
1727 */
1728 function desktop_mode_rest_ai_search( WP_REST_Request $request ) {
1729 $user_id = get_current_user_id();
1730 $query = $request->get_param( 'query' );
1731 $resume_tool = $request->get_param( 'resume_tool' );
1732 $start_offset = $request->get_param( 'start_offset' );
1733
1734 $command_tools = $request->get_param( 'command_tools' );
1735 if ( ! is_array( $command_tools ) ) {
1736 $command_tools = array();
1737 }
1738
1739 $extra = array(
1740 'user_id' => $user_id,
1741 'request_id' => function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ),
1742 'command_tools' => $command_tools,
1743 'system_prompt_text' => (string) $request->get_param( 'system_prompt_text' ),
1744 'system_prompt_mode' => (string) $request->get_param( 'system_prompt_mode' ),
1745 );
1746
1747 /**
1748 * Last-mile filter on the whole `/ai/search` request bundle.
1749 * Plugins get one hook to rewrite query, swap tools, or inject
1750 * metadata before the agent loop starts.
1751 *
1752 * @since 0.5.1
1753 *
1754 * @param array $extra Extended context (mutable).
1755 * @param array $core Core request params { query, resume_tool, start_offset }.
1756 */
1757 $extra = (array) apply_filters(
1758 'desktop_mode_ai_request',
1759 $extra,
1760 array(
1761 'query' => $query,
1762 'resume_tool' => $resume_tool,
1763 'start_offset' => $start_offset,
1764 )
1765 );
1766
1767 // Follow-up leg — skip the agent loop, summarise the tool outcome.
1768 $follow_up = $request->get_param( 'follow_up' );
1769 if ( is_array( $follow_up ) && isset( $follow_up['tool'] ) && is_array( $follow_up['tool'] ) ) {
1770 $tool = $follow_up['tool'];
1771 $outcome = isset( $follow_up['result'] )
1772 ? ( is_array( $follow_up['result'] ) ? $follow_up['result'] : array( 'value' => $follow_up['result'] ) )
1773 : array();
1774 $result = desktop_mode_ai_run_followup( $query, $tool, $outcome, $extra );
1775 } else {
1776 $result = desktop_mode_ai_run_search( $query, $resume_tool, $start_offset, null, $extra );
1777 }
1778
1779 if ( is_wp_error( $result ) ) {
1780 $request_id = isset( $extra['request_id'] ) ? (string) $extra['request_id'] : '';
1781 do_action(
1782 'desktop_mode_ai_search_error',
1783 array(
1784 'code' => $result->get_error_code(),
1785 'message' => $result->get_error_message(),
1786 'data' => $result->get_error_data(),
1787 'user_id' => $user_id,
1788 'request_id' => $request_id,
1789 )
1790 );
1791 return $result;
1792 }
1793
1794 return rest_ensure_response( $result );
1795 }
1796
1797 // ---------------------------------------------------------------------------
1798 // Tool: search_wporg_plugins — uses core's plugins_api() which queries
1799 // the official WordPress.org repository. Results are cached in a
1800 // 10-minute transient per query so repeated asks don't hammer w.org.
1801 // ---------------------------------------------------------------------------
1802
1803 /**
1804 * Search the WordPress.org plugin directory.
1805 *
1806 * @since 0.5.0
1807 *
1808 * @param string $query Search terms.
1809 * @return array Tool result payload ready for the model.
1810 */
1811 function desktop_mode_ai_fetch_wporg_plugins( $query ) {
1812 $query = trim( (string) $query );
1813 if ( $query === '' ) {
1814 return array(
1815 'tool' => 'search_wporg_plugins',
1816 'query' => '',
1817 'results' => array(),
1818 'count' => 0,
1819 'error' => 'No search query provided.',
1820 );
1821 }
1822
1823 // Transient cache to protect the w.org API from repeated queries
1824 // within the same conversation.
1825 $cache_key = 'desktop_mode_ai_plugins_' . md5( strtolower( $query ) );
1826 $cached = get_transient( $cache_key );
1827 if ( is_array( $cached ) ) {
1828 return $cached;
1829 }
1830
1831 if ( ! function_exists( 'plugins_api' ) ) {
1832 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
1833 }
1834
1835 $api = plugins_api(
1836 'query_plugins',
1837 array(
1838 'search' => $query,
1839 'per_page' => 10,
1840 'fields' => array(
1841 'short_description' => true,
1842 'description' => false,
1843 'sections' => false,
1844 'requires' => true,
1845 'tested' => true,
1846 'rating' => true,
1847 'ratings' => false,
1848 'downloaded' => false,
1849 'downloadlink' => false,
1850 'last_updated' => true,
1851 'added' => false,
1852 'tags' => false,
1853 'compatibility' => false,
1854 'homepage' => true,
1855 'versions' => false,
1856 'donate_link' => false,
1857 'reviews' => false,
1858 'banners' => false,
1859 'icons' => true,
1860 'active_installs' => true,
1861 'group' => false,
1862 'contributors' => false,
1863 ),
1864 )
1865 );
1866
1867 if ( is_wp_error( $api ) ) {
1868 return array(
1869 'tool' => 'search_wporg_plugins',
1870 'query' => $query,
1871 'results' => array(),
1872 'count' => 0,
1873 'error' => $api->get_error_message(),
1874 );
1875 }
1876
1877 $results = array();
1878 $plugins = isset( $api->plugins ) && is_array( $api->plugins ) ? $api->plugins : array();
1879 foreach ( $plugins as $p ) {
1880 // Normalise — plugins_api sometimes returns arrays, sometimes objects.
1881 $p = (array) $p;
1882
1883 $slug = isset( $p['slug'] ) ? (string) $p['slug'] : '';
1884 if ( $slug === '' ) {
1885 continue;
1886 }
1887
1888 $icon = '';
1889 $icons = isset( $p['icons'] ) && is_array( $p['icons'] ) ? $p['icons'] : array();
1890 if ( isset( $icons['1x'] ) ) {
1891 $icon = (string) $icons['1x'];
1892 } elseif ( isset( $icons['default'] ) ) {
1893 $icon = (string) $icons['default'];
1894 } elseif ( isset( $icons['svg'] ) ) {
1895 $icon = (string) $icons['svg'];
1896 }
1897
1898 // Admin URL that opens the plugin-information thickbox. Includes
1899 // both &plugin=slug and the TB_iframe params so clicking it in
1900 // wp-admin behaves like a native "More details" link.
1901 $install_admin_url = admin_url(
1902 'plugin-install.php?tab=plugin-information&plugin=' . rawurlencode( $slug )
1903 . '&TB_iframe=true&width=772&height=745'
1904 );
1905
1906 $results[] = array(
1907 'name' => wp_strip_all_tags( $p['name'] ?? '' ),
1908 'slug' => $slug,
1909 'short_description' => wp_strip_all_tags( $p['short_description'] ?? '' ),
1910 'version' => (string) ( $p['version'] ?? '' ),
1911 'author' => wp_strip_all_tags( $p['author'] ?? '' ),
1912 'rating' => (int) ( $p['rating'] ?? 0 ), // 0-100
1913 'num_ratings' => (int) ( $p['num_ratings'] ?? 0 ),
1914 'active_installs' => (int) ( $p['active_installs'] ?? 0 ),
1915 'last_updated' => (string) ( $p['last_updated'] ?? '' ),
1916 'requires' => (string) ( $p['requires'] ?? '' ),
1917 'tested' => (string) ( $p['tested'] ?? '' ),
1918 'homepage' => esc_url_raw( $p['homepage'] ?? '' ),
1919 'wporg_url' => 'https://wordpress.org/plugins/' . $slug . '/',
1920 'install_admin_url' => $install_admin_url,
1921 'icon' => esc_url_raw( $icon ),
1922 );
1923 }
1924
1925 $payload = array(
1926 'tool' => 'search_wporg_plugins',
1927 'query' => $query,
1928 'results' => $results,
1929 'count' => count( $results ),
1930 );
1931
1932 set_transient( $cache_key, $payload, 10 * MINUTE_IN_SECONDS );
1933
1934 return $payload;
1935 }
1936
1937 // ---------------------------------------------------------------------------
1938 // Tool: get_php_error_log — reads the tail of the site's error log.
1939 // Admin-only; the capability check is performed in the dispatcher
1940 // BEFORE this function runs, so by the time we get here the caller is
1941 // known to hold manage_options.
1942 // ---------------------------------------------------------------------------
1943
1944 /**
1945 * Read and parse the tail of the site's PHP error log.
1946 *
1947 * Tries WP_CONTENT_DIR/debug.log first (populated by WP_DEBUG_LOG), then
1948 * falls back to the PHP ini `error_log` directive. If neither points at
1949 * a readable file the tool reports log_available=false rather than
1950 * throwing.
1951 *
1952 * @since 0.5.0
1953 *
1954 * @param int $lines Number of lines to return (clamped 1-500 by caller).
1955 * @return array
1956 */
1957 function desktop_mode_ai_fetch_error_log( $lines = 50 ) {
1958 $candidates = array();
1959 if ( defined( 'WP_CONTENT_DIR' ) ) {
1960 $candidates[] = WP_CONTENT_DIR . '/debug.log';
1961 }
1962 $ini_log = (string) ini_get( 'error_log' );
1963 if ( $ini_log !== '' && 'syslog' !== $ini_log ) {
1964 $candidates[] = $ini_log;
1965 }
1966
1967 /**
1968 * Filter the list of log-file paths to probe in order. Plugins that
1969 * redirect errors somewhere non-standard can add their path here.
1970 *
1971 * @since 0.5.0
1972 *
1973 * @param string[] $candidates File paths, in probe order.
1974 */
1975 $candidates = (array) apply_filters( 'desktop_mode_ai_error_log_candidates', $candidates );
1976
1977 $log_path = '';
1978 foreach ( $candidates as $path ) {
1979 if ( is_string( $path ) && is_file( $path ) && is_readable( $path ) ) {
1980 $log_path = $path;
1981 break;
1982 }
1983 }
1984
1985 if ( $log_path === '' ) {
1986 return array(
1987 'tool' => 'get_php_error_log',
1988 'log_available' => false,
1989 'message' => 'No readable error log found. Enable WP_DEBUG_LOG in wp-config.php or set php_value error_log.',
1990 'checked_paths' => array_values( $candidates ),
1991 'entries' => array(),
1992 'count' => 0,
1993 );
1994 }
1995
1996 $tail = desktop_mode_ai_tail_file( $log_path, $lines );
1997
1998 $entries = array();
1999 foreach ( $tail as $line ) {
2000 $line = trim( $line );
2001 if ( $line === '' ) {
2002 continue;
2003 }
2004 $entries[] = desktop_mode_ai_parse_log_line( $line );
2005 }
2006
2007 return array(
2008 'tool' => 'get_php_error_log',
2009 'log_available' => true,
2010 'source' => $log_path,
2011 'entries' => $entries,
2012 'count' => count( $entries ),
2013 );
2014 }
2015
2016 /**
2017 * Parse a PHP error_log line into { timestamp, level, message }.
2018 *
2019 * PHP's default format is `[<date>] <prefix>: <message>` where the
2020 * prefix is usually "PHP Fatal error", "PHP Warning", etc. Falls back
2021 * to a raw line when the format doesn't match.
2022 *
2023 * @since 0.5.0
2024 *
2025 * @param string $line
2026 * @return array
2027 */
2028 function desktop_mode_ai_parse_log_line( $line ) {
2029 // Cap individual messages so a runaway stack trace doesn't balloon
2030 // the payload sent to the provider.
2031 $line = mb_substr( $line, 0, 600 );
2032
2033 $entry = array(
2034 'timestamp' => '',
2035 'level' => 'Log',
2036 'message' => $line,
2037 );
2038
2039 // [21-Apr-2026 10:30:22 UTC] PHP Fatal error: Uncaught Error: …
2040 if ( preg_match( '/^\[([^\]]+)\]\s*(.*)$/', $line, $m ) ) {
2041 $entry['timestamp'] = $m[1];
2042 $entry['message'] = $m[2];
2043 }
2044
2045 if ( preg_match( '/^(PHP (?:Fatal error|Parse error|Warning|Notice|Deprecated|Strict|Recoverable fatal error))/i', $entry['message'], $lm ) ) {
2046 $entry['level'] = $lm[1];
2047 $entry['message'] = trim( substr( $entry['message'], strlen( $lm[1] ) ), ":\t " );
2048 }
2049
2050 return $entry;
2051 }
2052
2053 /**
2054 * Return the last $lines of a file. Loads the file via WP_Filesystem
2055 * (the only file-read API allowed for wp.org-hosted plugins) and
2056 * slices off the trailing N+1 entries. Realistic error logs sit
2057 * in the kilobyte range when admins look at them; if a site routinely
2058 * lets logs grow into tens of MB, that's the symptom, not this read.
2059 *
2060 * @since 0.5.0
2061 *
2062 * @param string $path Absolute path to the file.
2063 * @param int $lines
2064 * @return string[] Lines in original order (oldest first).
2065 */
2066 function desktop_mode_ai_tail_file( $path, $lines ) {
2067 if ( ! function_exists( 'WP_Filesystem' ) ) {
2068 require_once ABSPATH . 'wp-admin/includes/file.php';
2069 }
2070 WP_Filesystem();
2071 global $wp_filesystem;
2072 if ( ! $wp_filesystem || ! $wp_filesystem->exists( $path ) ) {
2073 return array();
2074 }
2075
2076 $contents = $wp_filesystem->get_contents( $path );
2077 if ( false === $contents || '' === $contents ) {
2078 return array();
2079 }
2080
2081 $all = preg_split( '/\r?\n/', $contents );
2082 if ( ! is_array( $all ) ) {
2083 return array();
2084 }
2085
2086 return array_slice( $all, -1 * ( $lines + 1 ) );
2087 }
2088
2089 // ---------------------------------------------------------------------------
2090 // Streaming endpoint (Server-Sent Events)
2091 //
2092 // EventSource can't send POST or custom headers, so we ride admin-ajax.php
2093 // which handles cookie-based auth natively. The nonce goes in the URL.
2094 // Output buffering is forcibly disabled and every emit is flushed so the
2095 // browser receives progress ticks in real time.
2096 // ---------------------------------------------------------------------------
2097
2098 /**
2099 * admin-ajax handler for the streaming search endpoint.
2100 *
2101 * URL: /wp-admin/admin-ajax.php?action=desktop_mode_ai_search_stream
2102 * &nonce=<rest_nonce>
2103 * &query=<user question>
2104 * &resume_tool=<search_posts|…> (optional)
2105 * &start_offset=<int> (optional)
2106 *
2107 * Emits SSE events:
2108 * data: { "event": "progress", "phase": "tool_call", "message": "…" }
2109 * data: { "event": "done", "result": { … } }
2110 * data: { "event": "error", "message": "…" }
2111 *
2112 * @since 0.5.0
2113 */
2114 function desktop_mode_ai_ajax_search_stream() {
2115 $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
2116 if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
2117 status_header( 403 );
2118 exit;
2119 }
2120 if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
2121 status_header( 403 );
2122 exit;
2123 }
2124 $user_id = get_current_user_id();
2125 if ( ! desktop_mode_ai_is_available() ) {
2126 status_header( 503 );
2127 exit;
2128 }
2129 if ( ! desktop_mode_ai_is_enabled( $user_id ) ) {
2130 status_header( 403 );
2131 exit;
2132 }
2133
2134 $query = isset( $_GET['query'] ) ? sanitize_text_field( wp_unslash( $_GET['query'] ) ) : ''; // phpcs:ignore WordPress.Security
2135 if ( trim( $query ) === '' ) {
2136 status_header( 400 );
2137 exit;
2138 }
2139
2140 $resume_tool = isset( $_GET['resume_tool'] ) ? sanitize_key( wp_unslash( $_GET['resume_tool'] ) ) : null; // phpcs:ignore WordPress.Security
2141 $start_offset = isset( $_GET['start_offset'] ) ? absint( $_GET['start_offset'] ) : 0; // phpcs:ignore WordPress.Security
2142 if ( $resume_tool !== null && ! in_array( $resume_tool, desktop_mode_ai_search_resumable_tools(), true ) ) {
2143 $resume_tool = null;
2144 }
2145
2146 // SSE headers — tell nginx to stop buffering, tell the browser this is
2147 // a persistent event stream.
2148 header( 'Content-Type: text/event-stream; charset=utf-8' );
2149 header( 'Cache-Control: no-cache, no-store, must-revalidate' );
2150 header( 'X-Accel-Buffering: no' );
2151 header( 'Connection: keep-alive' );
2152
2153 // Let other requests from this user proceed (release session lock).
2154 if ( session_status() === PHP_SESSION_ACTIVE ) {
2155 session_write_close();
2156 }
2157
2158 // Kill any output buffers PHP set up, otherwise nothing flushes until
2159 // the request ends — which defeats the whole point of streaming.
2160 while ( ob_get_level() > 0 ) {
2161 @ob_end_flush(); // phpcs:ignore
2162 }
2163 @ini_set( 'output_buffering', 'off' ); // phpcs:ignore
2164 @ini_set( 'zlib.output_compression', 'off' ); // phpcs:ignore
2165 @set_time_limit( 120 ); // phpcs:ignore
2166
2167 $emit = static function ( array $payload ) {
2168 echo 'data: ' . wp_json_encode( $payload ) . "\n\n";
2169 @ob_flush(); // phpcs:ignore
2170 flush();
2171 };
2172
2173 // Initial tick so the EventSource opens immediately and the JS can
2174 // start showing "Thinking…" without waiting for the first model call.
2175 $emit( array( 'event' => 'open' ) );
2176
2177 $result = desktop_mode_ai_run_search(
2178 $query,
2179 $resume_tool,
2180 $start_offset,
2181 function ( $progress ) use ( $emit ) {
2182 $emit( array_merge( array( 'event' => 'progress' ), $progress ) );
2183 }
2184 );
2185
2186 if ( is_wp_error( $result ) ) {
2187 $emit( array(
2188 'event' => 'error',
2189 'message' => $result->get_error_message(),
2190 'code' => $result->get_error_code(),
2191 ) );
2192 } else {
2193 $emit( array(
2194 'event' => 'done',
2195 'result' => $result,
2196 ) );
2197 }
2198
2199 exit;
2200 }
2201 add_action( 'wp_ajax_desktop_mode_ai_search_stream', 'desktop_mode_ai_ajax_search_stream' );
2202