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

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