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

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

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