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

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