PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 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 All 36 releases
← All changes | includes/ai-copilot/search.php +809 -467 0.9.5 → 1.1.12 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — AI Copilot content search via the provider tool use.
3 + * OpenStation — AI Copilot content search via the provider tool use.
4 4 *
5 5 * Agentic search loop: the user describes something in natural language and
6 6 * the agent calls focused tools, choosing the right one based on query
7 7 * semantics. Built-in tools: four content-search tools — search_posts,
@@ -24,25 +24,25 @@
24 24 * - "Our About page mentions…" → agent calls search_pages.
25 25 * - Ambiguous queries → agent tries in priority order (posts → pages →
26 26 * comments) following the system-prompt guidance.
27 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.
28 + * Budget: max OPENSTATION_AI_SEARCH_MAX_ITERATIONS (10) tool-call rounds per
29 + * request × OPENSTATION_AI_SEARCH_BATCH_SIZE (10) items = up to 100 entities.
30 30 * When the budget is exhausted the response includes a `continue` object
31 31 * the client uses to resume from the exact offset that was last searched.
32 32 *
33 33 * REST endpoint: POST /desktop-mode/v1/ai/search
34 34 *
35 - * @package WPDesktopMode
35 + * @package OpenStation
36 36 */
37 37
38 38 defined( 'ABSPATH' ) || exit;
39 39
40 40 /** Maximum agentic tool-call iterations per search request. */
41 -const DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS = 10;
41 +const OPENSTATION_AI_SEARCH_MAX_ITERATIONS = 10;
42 42
43 43 /** Entities fetched per tool-call round. */
44 -const DESKTOP_MODE_AI_SEARCH_BATCH_SIZE = 10;
44 +const OPENSTATION_AI_SEARCH_BATCH_SIZE = 10;
45 45
46 46 /**
47 47 * Returns the catalog of common WordPress admin destinations.
48 48 *
@@ -50,58 +50,204 @@
50 50 * wp-admin URL (rendered through admin_url() so it respects the site's
51 51 * real admin path), a short description, and a Dashicons icon class the
52 52 * UI can use when opening the URL in a legacy iframe window.
53 53 *
54 - * Filterable via `desktop_mode_ai_admin_page_catalog` so third-party
54 + * Filterable via `openstation_ai_admin_page_catalog` so third-party
55 55 * plugins can contribute their own admin destinations (e.g. a plugin
56 56 * adding a top-level menu can surface its settings page here).
57 57 *
58 - * @since 0.5.0
59 - *
60 58 * @return array[]
61 59 */
62 -function desktop_mode_ai_get_admin_page_catalog() {
60 +function openstation_ai_get_admin_page_catalog() {
63 61 $catalog = array(
64 - array( 'title' => 'Dashboard', 'url' => admin_url( 'index.php' ), 'icon' => 'dashicons-dashboard', 'description' => 'The main admin dashboard — activity, drafts, site overview.' ),
65 - array( 'title' => 'All Posts', 'url' => admin_url( 'edit.php' ), 'icon' => 'dashicons-admin-post', 'description' => 'List, edit, bulk-manage blog posts.' ),
66 - array( 'title' => 'Add New Post', 'url' => admin_url( 'post-new.php' ), 'icon' => 'dashicons-plus', 'description' => 'Create a new blog post.' ),
67 - array( 'title' => 'Categories', 'url' => admin_url( 'edit-tags.php?taxonomy=category' ), 'icon' => 'dashicons-category', 'description' => 'Manage post categories — add, rename, merge.' ),
68 - array( 'title' => 'Tags', 'url' => admin_url( 'edit-tags.php?taxonomy=post_tag' ), 'icon' => 'dashicons-tag', 'description' => 'Manage post tags.' ),
69 - array( 'title' => 'All Pages', 'url' => admin_url( 'edit.php?post_type=page' ), 'icon' => 'dashicons-admin-page', 'description' => 'List and edit static pages (About, Contact, etc.).' ),
70 - array( 'title' => 'Add New Page', 'url' => admin_url( 'post-new.php?post_type=page' ), 'icon' => 'dashicons-plus', 'description' => 'Create a new page.' ),
71 - array( 'title' => 'Media Library', 'url' => admin_url( 'upload.php' ), 'icon' => 'dashicons-admin-media', 'description' => 'Browse, upload, and manage images, files, videos.' ),
72 - array( 'title' => 'Comments', 'url' => admin_url( 'edit-comments.php' ), 'icon' => 'dashicons-admin-comments', 'description' => 'Moderate and reply to comments on posts and pages.' ),
73 - array( 'title' => 'Themes', 'url' => admin_url( 'themes.php' ), 'icon' => 'dashicons-admin-appearance', 'description' => 'Change, install, or customize the active theme.' ),
74 - array( 'title' => 'Customize', 'url' => admin_url( 'customize.php' ), 'icon' => 'dashicons-admin-customizer', 'description' => 'Live-preview theme customisation — colors, fonts, layout.' ),
75 - array( 'title' => 'Widgets', 'url' => admin_url( 'widgets.php' ), 'icon' => 'dashicons-screenoptions', 'description' => 'Manage sidebar and footer widgets.' ),
76 - array( 'title' => 'Menus', 'url' => admin_url( 'nav-menus.php' ), 'icon' => 'dashicons-menu', 'description' => 'Create and edit navigation menus.' ),
77 - array( 'title' => 'Plugins', 'url' => admin_url( 'plugins.php' ), 'icon' => 'dashicons-admin-plugins', 'description' => 'Activate, deactivate, update or delete plugins.' ),
78 - array( 'title' => 'Add New Plugin', 'url' => admin_url( 'plugin-install.php' ), 'icon' => 'dashicons-plus', 'description' => 'Search and install new plugins from the directory.' ),
79 - array( 'title' => 'Users', 'url' => admin_url( 'users.php' ), 'icon' => 'dashicons-admin-users', 'description' => 'Manage user accounts and roles.' ),
80 - array( 'title' => 'Add New User', 'url' => admin_url( 'user-new.php' ), 'icon' => 'dashicons-plus', 'description' => 'Create a new user account.' ),
81 - array( 'title' => 'Your Profile', 'url' => admin_url( 'profile.php' ), 'icon' => 'dashicons-id', 'description' => 'Edit your own profile, password, admin colour scheme.' ),
82 - array( 'title' => 'General Settings', 'url' => admin_url( 'options-general.php' ), 'icon' => 'dashicons-admin-settings', 'description' => 'Site title, tagline, URL, timezone, language.' ),
83 - array( 'title' => 'Writing Settings', 'url' => admin_url( 'options-writing.php' ), 'icon' => 'dashicons-edit', 'description' => 'Default post category, post format, remote publishing.' ),
84 - array( 'title' => 'Reading Settings', 'url' => admin_url( 'options-reading.php' ), 'icon' => 'dashicons-book', 'description' => 'Homepage, blog posts per page, search-engine visibility.' ),
85 - array( 'title' => 'Discussion Settings','url' => admin_url( 'options-discussion.php' ), 'icon' => 'dashicons-format-chat', 'description' => 'Comment moderation, avatars, email notifications.' ),
86 - array( 'title' => 'Media Settings', 'url' => admin_url( 'options-media.php' ), 'icon' => 'dashicons-format-image', 'description' => 'Image size settings for thumbnail / medium / large.' ),
87 - array( 'title' => 'Permalinks', 'url' => admin_url( 'options-permalink.php' ), 'icon' => 'dashicons-admin-links', 'description' => 'URL structure for posts, pages, categories, tags.' ),
88 - array( 'title' => 'Privacy', 'url' => admin_url( 'options-privacy.php' ), 'icon' => 'dashicons-privacy', 'description' => 'Privacy policy page selection and preview.' ),
89 - array( 'title' => 'Tools', 'url' => admin_url( 'tools.php' ), 'icon' => 'dashicons-admin-tools', 'description' => 'Built-in site tools.' ),
90 - array( 'title' => 'Import', 'url' => admin_url( 'import.php' ), 'icon' => 'dashicons-download', 'description' => 'Import content from other platforms (WP, Tumblr, RSS, etc.).' ),
91 - array( 'title' => 'Export', 'url' => admin_url( 'export.php' ), 'icon' => 'dashicons-upload', 'description' => 'Export all site content as XML.' ),
92 - array( 'title' => 'Site Health', 'url' => admin_url( 'site-health.php' ), 'icon' => 'dashicons-heart', 'description' => 'Performance and security recommendations for the site.' ),
93 - array( 'title' => 'Updates', 'url' => admin_url( 'update-core.php' ), 'icon' => 'dashicons-update', 'description' => 'WordPress, theme, and plugin updates.' ),
62 + array(
63 + 'title' => __( 'Dashboard', 'desktop-mode' ),
64 + 'url' => admin_url( 'index.php' ),
65 + 'icon' => 'dashicons-dashboard',
66 + 'description' => __( 'The main admin dashboard: activity, drafts, site overview.', 'desktop-mode' ),
67 + ),
68 + array(
69 + 'title' => __( 'All Posts', 'desktop-mode' ),
70 + 'url' => admin_url( 'edit.php' ),
71 + 'icon' => 'dashicons-admin-post',
72 + 'description' => __( 'List, edit, bulk-manage blog posts.', 'desktop-mode' ),
73 + ),
74 + array(
75 + 'title' => __( 'Add New Post', 'desktop-mode' ),
76 + 'url' => admin_url( 'post-new.php' ),
77 + 'icon' => 'dashicons-plus',
78 + 'description' => __( 'Create a new blog post.', 'desktop-mode' ),
79 + ),
80 + array(
81 + 'title' => __( 'Categories', 'desktop-mode' ),
82 + 'url' => admin_url( 'edit-tags.php?taxonomy=category' ),
83 + 'icon' => 'dashicons-category',
84 + 'description' => __( 'Manage post categories: add, rename, merge.', 'desktop-mode' ),
85 + ),
86 + array(
87 + 'title' => __( 'Tags', 'desktop-mode' ),
88 + 'url' => admin_url( 'edit-tags.php?taxonomy=post_tag' ),
89 + 'icon' => 'dashicons-tag',
90 + 'description' => __( 'Manage post tags.', 'desktop-mode' ),
91 + ),
92 + array(
93 + 'title' => __( 'All Pages', 'desktop-mode' ),
94 + 'url' => admin_url( 'edit.php?post_type=page' ),
95 + 'icon' => 'dashicons-admin-page',
96 + 'description' => __( 'List and edit static pages (About, Contact, etc.).', 'desktop-mode' ),
97 + ),
98 + array(
99 + 'title' => __( 'Add New Page', 'desktop-mode' ),
100 + 'url' => admin_url( 'post-new.php?post_type=page' ),
101 + 'icon' => 'dashicons-plus',
102 + 'description' => __( 'Create a new page.', 'desktop-mode' ),
103 + ),
104 + array(
105 + 'title' => __( 'Media Library', 'desktop-mode' ),
106 + 'url' => admin_url( 'upload.php' ),
107 + 'icon' => 'dashicons-admin-media',
108 + 'description' => __( 'Browse, upload, and manage images, files, videos.', 'desktop-mode' ),
109 + ),
110 + array(
111 + 'title' => __( 'Comments', 'desktop-mode' ),
112 + 'url' => admin_url( 'edit-comments.php' ),
113 + 'icon' => 'dashicons-admin-comments',
114 + 'description' => __( 'Moderate and reply to comments on posts and pages.', 'desktop-mode' ),
115 + ),
116 + array(
117 + 'title' => __( 'Themes', 'desktop-mode' ),
118 + 'url' => admin_url( 'themes.php' ),
119 + 'icon' => 'dashicons-admin-appearance',
120 + 'description' => __( 'Change, install, or customize the active theme.', 'desktop-mode' ),
121 + ),
122 + array(
123 + 'title' => __( 'Customize', 'desktop-mode' ),
124 + 'url' => admin_url( 'customize.php' ),
125 + 'icon' => 'dashicons-admin-customizer',
126 + 'description' => __( 'Live-preview theme customisation: colors, fonts, layout.', 'desktop-mode' ),
127 + ),
128 + array(
129 + 'title' => __( 'Widgets', 'desktop-mode' ),
130 + 'url' => admin_url( 'widgets.php' ),
131 + 'icon' => 'dashicons-screenoptions',
132 + 'description' => __( 'Manage sidebar and footer widgets.', 'desktop-mode' ),
133 + ),
134 + array(
135 + 'title' => __( 'Menus', 'desktop-mode' ),
136 + 'url' => admin_url( 'nav-menus.php' ),
137 + 'icon' => 'dashicons-menu',
138 + 'description' => __( 'Create and edit navigation menus.', 'desktop-mode' ),
139 + ),
140 + array(
141 + 'title' => __( 'Plugins', 'desktop-mode' ),
142 + 'url' => admin_url( 'plugins.php' ),
143 + 'icon' => 'dashicons-admin-plugins',
144 + 'description' => __( 'Activate, deactivate, update or delete plugins.', 'desktop-mode' ),
145 + ),
146 + array(
147 + 'title' => __( 'Add New Plugin', 'desktop-mode' ),
148 + 'url' => admin_url( 'plugin-install.php' ),
149 + 'icon' => 'dashicons-plus',
150 + 'description' => __( 'Search and install new plugins from the directory.', 'desktop-mode' ),
151 + ),
152 + array(
153 + 'title' => __( 'Users', 'desktop-mode' ),
154 + 'url' => admin_url( 'users.php' ),
155 + 'icon' => 'dashicons-admin-users',
156 + 'description' => __( 'Manage user accounts and roles.', 'desktop-mode' ),
157 + ),
158 + array(
159 + 'title' => __( 'Add New User', 'desktop-mode' ),
160 + 'url' => admin_url( 'user-new.php' ),
161 + 'icon' => 'dashicons-plus',
162 + 'description' => __( 'Create a new user account.', 'desktop-mode' ),
163 + ),
164 + array(
165 + 'title' => __( 'Your Profile', 'desktop-mode' ),
166 + 'url' => admin_url( 'profile.php' ),
167 + 'icon' => 'dashicons-id',
168 + 'description' => __( 'Edit your own profile, password, admin colour scheme.', 'desktop-mode' ),
169 + ),
170 + array(
171 + 'title' => __( 'General Settings', 'desktop-mode' ),
172 + 'url' => admin_url( 'options-general.php' ),
173 + 'icon' => 'dashicons-admin-settings',
174 + 'description' => __( 'Site title, tagline, URL, timezone, language.', 'desktop-mode' ),
175 + ),
176 + array(
177 + 'title' => __( 'Writing Settings', 'desktop-mode' ),
178 + 'url' => admin_url( 'options-writing.php' ),
179 + 'icon' => 'dashicons-edit',
180 + 'description' => __( 'Default post category, post format, remote publishing.', 'desktop-mode' ),
181 + ),
182 + array(
183 + 'title' => __( 'Reading Settings', 'desktop-mode' ),
184 + 'url' => admin_url( 'options-reading.php' ),
185 + 'icon' => 'dashicons-book',
186 + 'description' => __( 'Homepage, blog posts per page, search-engine visibility.', 'desktop-mode' ),
187 + ),
188 + array(
189 + 'title' => __( 'Discussion Settings', 'desktop-mode' ),
190 + 'url' => admin_url( 'options-discussion.php' ),
191 + 'icon' => 'dashicons-format-chat',
192 + 'description' => __( 'Comment moderation, avatars, email notifications.', 'desktop-mode' ),
193 + ),
194 + array(
195 + 'title' => __( 'Media Settings', 'desktop-mode' ),
196 + 'url' => admin_url( 'options-media.php' ),
197 + 'icon' => 'dashicons-format-image',
198 + 'description' => __( 'Image size settings for thumbnail / medium / large.', 'desktop-mode' ),
199 + ),
200 + array(
201 + 'title' => __( 'Permalinks', 'desktop-mode' ),
202 + 'url' => admin_url( 'options-permalink.php' ),
203 + 'icon' => 'dashicons-admin-links',
204 + 'description' => __( 'URL structure for posts, pages, categories, tags.', 'desktop-mode' ),
205 + ),
206 + array(
207 + 'title' => __( 'Privacy', 'desktop-mode' ),
208 + 'url' => admin_url( 'options-privacy.php' ),
209 + 'icon' => 'dashicons-privacy',
210 + 'description' => __( 'Privacy policy page selection and preview.', 'desktop-mode' ),
211 + ),
212 + array(
213 + 'title' => __( 'Tools', 'desktop-mode' ),
214 + 'url' => admin_url( 'tools.php' ),
215 + 'icon' => 'dashicons-admin-tools',
216 + 'description' => __( 'Built-in site tools.', 'desktop-mode' ),
217 + ),
218 + array(
219 + 'title' => __( 'Import', 'desktop-mode' ),
220 + 'url' => admin_url( 'import.php' ),
221 + 'icon' => 'dashicons-download',
222 + 'description' => __( 'Import content from other platforms (WP, Tumblr, RSS, etc.).', 'desktop-mode' ),
223 + ),
224 + array(
225 + 'title' => __( 'Export', 'desktop-mode' ),
226 + 'url' => admin_url( 'export.php' ),
227 + 'icon' => 'dashicons-upload',
228 + 'description' => __( 'Export all site content as XML.', 'desktop-mode' ),
229 + ),
230 + array(
231 + 'title' => __( 'Site Health', 'desktop-mode' ),
232 + 'url' => admin_url( 'site-health.php' ),
233 + 'icon' => 'dashicons-heart',
234 + 'description' => __( 'Performance and security recommendations for the site.', 'desktop-mode' ),
235 + ),
236 + array(
237 + 'title' => __( 'Updates', 'desktop-mode' ),
238 + 'url' => admin_url( 'update-core.php' ),
239 + 'icon' => 'dashicons-update',
240 + 'description' => __( 'WordPress, theme, and plugin updates.', 'desktop-mode' ),
241 + ),
94 242 );
95 243
96 244 /**
97 245 * Filters the wp-admin page catalog surfaced by the AI assistant.
98 246 *
99 - * @since 0.5.0
100 - *
101 247 * @param array[] $catalog Array of entries, each with title/url/icon/description.
102 248 */
103 - return (array) apply_filters( 'desktop_mode_ai_admin_page_catalog', $catalog );
249 + return (array) apply_filters( 'openstation_ai_admin_page_catalog', $catalog );
104 250 }
105 251
106 252 // ---------------------------------------------------------------------------
107 253 // Final-answer JSON Schema
@@ -109,13 +255,11 @@
109 255
110 256 /**
111 257 * JSON Schema for the agent's final structured answer.
112 258 *
113 - * @since 0.5.0
114 - *
115 259 * @return array
116 260 */
117 -function desktop_mode_ai_search_answer_schema() {
261 +function openstation_ai_search_answer_schema() {
118 262 return array(
119 263 'type' => 'object',
120 264 'additionalProperties' => false,
121 265 'required' => array( 'answer_type', 'message', 'entity_id', 'entity_type', 'admin_links' ),
@@ -122,9 +266,9 @@
122 266 'properties' => array(
123 267 'answer_type' => array(
124 268 'type' => 'string',
125 269 'enum' => array( 'entity', 'navigation', 'chat' ),
126 - 'description' => 'Classification of the answer: "entity" when you identified a specific post/page/comment the user was asking about. "navigation" when the user asked where to find something in wp-admin and you are returning admin_links. "chat" for conversational responses that don\'t involve finding content or navigation (e.g. greetings, clarifications, "I couldn\'t find anything").',
270 + 'description' => 'Classification of the answer: "entity" when you identified a specific post/page/comment the user was asking about. "navigation" when you are returning admin_links: wp-admin destinations or plugin install links. "chat" for everything else, including summaries of tool results (error logs, site info), greetings, clarifications and "I couldn\'t find anything".',
127 271 ),
128 272 'message' => array(
129 273 'type' => 'string',
130 274 '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").',
@@ -137,9 +281,12 @@
137 281 'description' => 'The WordPress ID of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
138 282 ),
139 283 'entity_type' => array(
140 284 'anyOf' => array(
141 - array( 'type' => 'string', 'enum' => array( 'post', 'page', 'comment' ) ),
285 + array(
286 + 'type' => 'string',
287 + 'enum' => array( 'post', 'page', 'comment' ),
288 + ),
142 289 array( 'type' => 'null' ),
143 290 ),
144 291 'description' => 'Type of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
145 292 ),
@@ -179,36 +326,34 @@
179 326 * matched with WordPress's native search; `search_comments_by_post`
180 327 * needs an additional `post_id`. The caller passes the full decoded
181 328 * arguments array so this function can extract whatever it needs.
182 329 *
183 - * @since 0.5.0
184 - *
185 330 * @param string $tool_name Tool function name.
186 331 * @param array $args Decoded arguments from the model's tool call.
187 332 * @return array Tool result payload.
188 333 */
189 -function desktop_mode_ai_search_dispatch_tool( $tool_name, array $args ) {
334 +function openstation_ai_search_dispatch_tool( $tool_name, array $args ) {
190 335 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
191 336 $query = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : '';
192 337
193 338 switch ( $tool_name ) {
194 339 case 'search_posts':
195 - return desktop_mode_ai_search_fetch_posts( 'post', $query, $offset );
340 + return openstation_ai_search_fetch_posts( 'post', $query, $offset );
196 341 case 'search_pages':
197 - return desktop_mode_ai_search_fetch_posts( 'page', $query, $offset );
342 + return openstation_ai_search_fetch_posts( 'page', $query, $offset );
198 343 case 'search_comments':
199 - return desktop_mode_ai_search_fetch_comments( $query, $offset );
344 + return openstation_ai_search_fetch_comments( $query, $offset );
200 345 case 'search_comments_by_post':
201 346 $post_id = max( 0, (int) ( $args['post_id'] ?? 0 ) );
202 - return desktop_mode_ai_search_fetch_comments_by_post( $post_id, $query, $offset );
347 + return openstation_ai_search_fetch_comments_by_post( $post_id, $query, $offset );
203 348 case 'list_admin_pages':
204 349 return array(
205 350 'tool' => 'list_admin_pages',
206 - 'pages' => desktop_mode_ai_get_admin_page_catalog(),
351 + 'pages' => openstation_ai_get_admin_page_catalog(),
207 352 );
208 353 case 'search_wporg_plugins':
209 354 $q = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : '';
210 - return desktop_mode_ai_fetch_wporg_plugins( $q );
355 + return openstation_ai_fetch_wporg_plugins( $q );
211 356 case 'get_php_error_log':
212 357 if ( ! current_user_can( 'manage_options' ) ) {
213 358 return array(
214 359 'tool' => 'get_php_error_log',
@@ -217,9 +362,9 @@
217 362 'entries' => array(),
218 363 );
219 364 }
220 365 $lines = isset( $args['lines'] ) ? max( 1, min( 500, (int) $args['lines'] ) ) : 50;
221 - return desktop_mode_ai_fetch_error_log( $lines );
366 + return openstation_ai_fetch_error_log( $lines );
222 367 }
223 368
224 369 return array(
225 370 'tool' => $tool_name,
@@ -236,11 +381,15 @@
236 381 * Keyword-searches published posts or pages with WordPress's native search
237 382 * (`WP_Query` `s=`), returning data rich enough for the agent to compare
238 383 * AND for the UI to render links.
239 384 *
240 - * No AI analysis is required — every published post/page is searchable.
385 + * No AI analysis is required — every published, non-password-protected post/page is searchable.
241 386 *
242 - * @since 0.5.0
387 + * Password-protected posts are excluded (`has_password => false`): `publish`
388 + * is also the status of a password-protected post, and this tool emits the
389 + * stored body as an excerpt without ever passing through `post_password_required()`.
390 + * Filtering at the query level keeps them out of both `items` and `found_posts`,
391 + * so the `total` counter cannot become an oracle for their contents either.
243 392 *
244 393 * @param string $post_type 'post' | 'page'.
245 394 * @param string $query Keyword search terms (may be empty to list newest).
246 395 * @param int $offset
@@ -245,15 +394,16 @@
245 394 * @param string $query Keyword search terms (may be empty to list newest).
246 395 * @param int $offset
247 396 * @return array
248 397 */
249 -function desktop_mode_ai_search_fetch_posts( $post_type, $query, $offset ) {
398 +function openstation_ai_search_fetch_posts( $post_type, $query, $offset ) {
250 399 $wp_query = new WP_Query(
251 400 array(
252 401 'post_type' => $post_type,
253 402 'post_status' => 'publish',
403 + 'has_password' => false,
254 404 's' => (string) $query,
255 - 'posts_per_page' => DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
405 + 'posts_per_page' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
256 406 'offset' => $offset,
257 407 'no_found_rows' => false,
258 408 'update_post_term_cache' => false,
259 409 'update_post_meta_cache' => false,
@@ -267,9 +417,9 @@
267 417 'id' => $post->ID,
268 418 'type' => $post->post_type,
269 419 // Comparison data for the model — real title + content excerpt.
270 420 'title' => wp_strip_all_tags( $post->post_title ),
271 - 'excerpt' => desktop_mode_ai_search_excerpt( $post->post_content ),
421 + 'excerpt' => openstation_ai_search_excerpt( $post->post_content ),
272 422 'date' => $post->post_date ? substr( $post->post_date, 0, 10 ) : '',
273 423 // Links — passed through so the UI can link to the entity
274 424 // once the agent identifies a match.
275 425 'url' => (string) get_permalink( $post ),
@@ -285,10 +435,10 @@
285 435 'offset' => $offset,
286 436 'items' => $items,
287 437 'count' => count( $items ),
288 438 'total' => $total,
289 - 'has_more' => ( $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE ) < $total,
290 - 'next_offset' => $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
439 + 'has_more' => ( $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE ) < $total,
440 + 'next_offset' => $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE,
291 441 );
292 442 }
293 443
294 444 /**
@@ -293,14 +443,12 @@
293 443
294 444 /**
295 445 * Trims raw post/comment content into a plain-text excerpt for the model.
296 446 *
297 - * @since 0.9.1
298 - *
299 447 * @param string $content Raw post/comment content.
300 448 * @return string
301 449 */
302 -function desktop_mode_ai_search_excerpt( $content ) {
450 +function openstation_ai_search_excerpt( $content ) {
303 451 $text = wp_strip_all_tags( (string) $content );
304 452 $text = preg_replace( '/\s+/', ' ', trim( $text ) );
305 453 return (string) mb_substr( $text, 0, 300 );
306 454 }
@@ -305,20 +453,92 @@
305 453 return (string) mb_substr( $text, 0, 300 );
306 454 }
307 455
308 456 /**
457 + * Whether the current user may read a post the comment tools are about to
458 + * surface.
459 + *
460 + * A comment being `approved` is a moderation decision — it says nothing about
461 + * who may see the discussion. An approved comment can hang on a private,
462 + * draft, or password-protected post the caller cannot reach, so the comment
463 + * search tools must gate on the PARENT POST's visibility before returning the
464 + * comment text or the parent title. Mirrors Core's
465 + * `WP_REST_Comments_Controller::check_read_post_permission()`:
466 + *
467 + * - a password-protected parent needs the password satisfied or `edit_post`.
468 + * `post_password_required()` honours the `wp-postpass` cookie Core's
469 + * password form sets, and that is deliberate Core parity, not a gap: the
470 + * cookie only exists because the caller already entered the correct
471 + * password, and Core's comments controller reads the same cookie. The
472 + * ability itself has no password input, so a caller who never unlocked
473 + * the post front-end is refused;
474 + * - a publicly viewable parent (public status AND viewable post type) is
475 + * readable by anyone the ability admits;
476 + * - a parent whose post TYPE is not viewable (an internal/admin-only CPT)
477 + * needs `edit_post` — `read_post` cannot stand in, because a public status
478 + * resolves it to plain `read` whatever the type's visibility, which is how
479 + * Core's REST layer needs its own post-type gate too;
480 + * - any other parent (private, draft, pending, …) needs `read_post`.
481 + *
482 + * @param int|WP_Post $post Post ID or object.
483 + * @return bool
484 + */
485 +function openstation_ai_can_read_post( $post ) {
486 + // An id of 0 must stay unreadable: get_post( 0 ) falls back to the global
487 + // $post, which would judge an orphaned comment against an unrelated post.
488 + if ( is_numeric( $post ) && (int) $post <= 0 ) {
489 + return false;
490 + }
491 +
492 + $post = get_post( $post );
493 + if ( ! $post instanceof WP_Post ) {
494 + return false;
495 + }
496 +
497 + if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
498 + return false;
499 + }
500 +
501 + if ( is_post_publicly_viewable( $post ) ) {
502 + return true;
503 + }
504 +
505 + $post_type = get_post_type_object( $post->post_type );
506 + if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
507 + return current_user_can( 'edit_post', $post->ID );
508 + }
509 +
510 + return current_user_can( 'read_post', $post->ID );
511 +}
512 +
513 +/**
514 + * Whether the current user may read the post a comment is attached to.
515 + *
516 + * Used to drop comments on posts the caller cannot see from the comment
517 + * search results. See {@see openstation_ai_can_read_post()}.
518 + *
519 + * @param int|WP_Comment $comment Comment ID or object.
520 + * @return bool
521 + */
522 +function openstation_ai_can_read_comment_parent( $comment ) {
523 + $comment = get_comment( $comment );
524 + if ( ! $comment instanceof WP_Comment ) {
525 + return false;
526 + }
527 + return openstation_ai_can_read_post( (int) $comment->comment_post_ID );
528 +}
529 +
530 +/**
309 531 * Keyword-searches approved comments across all posts with WordPress's
310 532 * native comment search (`get_comments` `search=`).
311 533 *
312 534 * No AI analysis is required — every approved comment is searchable.
313 535 *
314 - * @since 0.5.0
315 - *
316 536 * @param string $query Keyword search terms (may be empty to list newest).
317 537 * @param int $offset
318 538 * @return array
319 539 */
320 -function desktop_mode_ai_search_fetch_comments( $query, $offset ) {
540 +function openstation_ai_search_fetch_comments( $query, $offset ) {
321 541 $base_args = array(
322 542 'status' => 'approve',
323 543 'type' => 'comment',
324 544 'search' => (string) $query,
@@ -323,32 +543,55 @@
323 543 'type' => 'comment',
324 544 'search' => (string) $query,
325 545 );
326 546
327 - $comments = get_comments( array_merge( $base_args, array(
328 - 'number' => DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
329 - 'offset' => $offset,
330 - 'count' => false,
331 - ) ) );
547 + $comments = get_comments(
548 + array_merge(
549 + $base_args,
550 + array(
551 + 'number' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
552 + 'offset' => $offset,
553 + 'count' => false,
554 + )
555 + )
556 + );
332 557
333 558 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
334 559
335 560 // Prime the parent posts in a single query so the per-comment
336 561 // get_post() calls below are cache hits, not N+1 round-trips.
337 - $parent_ids = array_unique( array_map(
338 - static function ( $c ) {
339 - return (int) $c->comment_post_ID;
340 - },
341 - $comments
342 - ) );
562 + $parent_ids = array_unique(
563 + array_map(
564 + static function ( $c ) {
565 + return (int) $c->comment_post_ID;
566 + },
567 + $comments
568 + )
569 + );
343 570 if ( $parent_ids ) {
344 571 _prime_post_caches( $parent_ids, false, false );
345 572 }
346 573
574 + // "Approved" is a moderation decision, not a visibility one: drop comments
575 + // whose parent post the caller cannot read (private / draft / password /
576 + // internal CPT), so the comment text and the parent title never leak. See
577 + // openstation_ai_can_read_comment_parent().
578 + //
579 + // This runs per row, after the batch, and that is the price of gating on
580 + // per-caller readability: an Administrator reads comments on private
581 + // posts and a reader who entered a post password reads that post's
582 + // discussion, neither of which a single `post_status` or `has_password`
583 + // query var can express. `total` therefore counts rows this caller does
584 + // not get, and a batch can come back short. The alternative — a blanket
585 + // publish-only, no-password query — would be exact and would also hide
586 + // those discussions from the people entitled to them.
587 + $comments = array_values( array_filter( $comments, 'openstation_ai_can_read_comment_parent' ) );
588 +
347 589 $items = array();
348 590 foreach ( $comments as $comment ) {
591 + // Readable, per the filter above.
349 592 $parent_post = get_post( $comment->comment_post_ID );
350 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
593 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
351 594
352 595 $items[] = array(
353 596 'id' => (int) $comment->comment_ID,
354 597 'type' => 'comment',
@@ -353,14 +596,14 @@
353 596 'id' => (int) $comment->comment_ID,
354 597 'type' => 'comment',
355 598 // Comparison data — real comment text + parent post title.
356 599 'post_title' => $parent_title,
357 - 'excerpt' => desktop_mode_ai_search_excerpt( $comment->comment_content ),
600 + 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
358 601 // Links.
359 602 'url' => (string) get_comment_link( $comment ),
360 603 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
361 604 'post_id' => (int) $comment->comment_post_ID,
362 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
605 + 'post_url' => (string) get_permalink( $parent_post ),
363 606 );
364 607 }
365 608
366 609 return array(
@@ -369,10 +612,10 @@
369 612 'offset' => $offset,
370 613 'items' => $items,
371 614 'count' => count( $items ),
372 615 'total' => $total,
373 - 'has_more' => ( $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE ) < $total,
374 - 'next_offset' => $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
616 + 'has_more' => ( $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE ) < $total,
617 + 'next_offset' => $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE,
375 618 );
376 619 }
377 620
378 621 // ---------------------------------------------------------------------------
@@ -386,16 +629,14 @@
386 629 * identifying a post via `search_posts`, giving it a scoped, precise set of
387 630 * comments to compare against the user's description. An empty `$query`
388 631 * lists the post's comments without keyword filtering.
389 632 *
390 - * @since 0.5.0
391 - *
392 633 * @param int $post_id The WordPress post ID.
393 634 * @param string $query Keyword search terms (may be empty).
394 635 * @param int $offset
395 636 * @return array Tool result payload.
396 637 */
397 -function desktop_mode_ai_search_fetch_comments_by_post( $post_id, $query, $offset ) {
638 +function openstation_ai_search_fetch_comments_by_post( $post_id, $query, $offset ) {
398 639 $post_id = (int) $post_id;
399 640
400 641 if ( $post_id <= 0 ) {
401 642 return array(
@@ -409,8 +650,25 @@
409 650 'error' => 'post_id must be a positive integer.',
410 651 );
411 652 }
412 653
654 + // The model picks the post id, so it is untrusted the same way an entity
655 + // id is. Comments inherit their parent's reach: a thread on a private,
656 + // draft, password-protected or internal-CPT post is not this user's to
657 + // read, and the envelope below would otherwise echo its title back.
658 + if ( ! openstation_ai_can_read_post( $post_id ) ) {
659 + return array(
660 + 'tool' => 'search_comments_by_post',
661 + 'post_id' => $post_id,
662 + 'offset' => $offset,
663 + 'items' => array(),
664 + 'count' => 0,
665 + 'total' => 0,
666 + 'has_more' => false,
667 + 'error' => 'Post not found or not readable.',
668 + );
669 + }
670 +
413 671 $base_args = array(
414 672 'post_id' => $post_id,
415 673 'status' => 'approve',
416 674 'type' => 'comment',
@@ -416,18 +674,24 @@
416 674 'type' => 'comment',
417 675 'search' => (string) $query,
418 676 );
419 677
420 - $comments = get_comments( array_merge( $base_args, array(
421 - 'number' => DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
422 - 'offset' => $offset,
423 - 'count' => false,
424 - ) ) );
678 + $comments = get_comments(
679 + array_merge(
680 + $base_args,
681 + array(
682 + 'number' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
683 + 'offset' => $offset,
684 + 'count' => false,
685 + )
686 + )
687 + );
425 688
426 689 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
427 690
691 + // Readable, per the gate above.
428 692 $parent_post = get_post( $post_id );
429 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
693 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
430 694
431 695 $items = array();
432 696 foreach ( $comments as $comment ) {
433 697 $items[] = array(
@@ -434,9 +698,9 @@
434 698 'id' => (int) $comment->comment_ID,
435 699 'type' => 'comment',
436 700 'post_id' => $post_id,
437 701 'post_title' => $parent_title,
438 - 'excerpt' => desktop_mode_ai_search_excerpt( $comment->comment_content ),
702 + 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
439 703 'url' => (string) get_comment_link( $comment ),
440 704 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
441 705 );
442 706 }
@@ -449,10 +713,10 @@
449 713 'offset' => $offset,
450 714 'items' => $items,
451 715 'count' => count( $items ),
452 716 'total' => $total,
453 - 'has_more' => ( $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE ) < $total,
454 - 'next_offset' => $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
717 + 'has_more' => ( $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE ) < $total,
718 + 'next_offset' => $offset + OPENSTATION_AI_SEARCH_BATCH_SIZE,
455 719 );
456 720 }
457 721
458 722 // ---------------------------------------------------------------------------
@@ -467,53 +731,100 @@
467 731 * required. Comments opportunistically surface the `spam` / `harmful`
468 732 * verdict when the comment-moderation analysis happens to have run, but
469 733 * its absence never blocks the entity from being returned.
470 734 *
471 - * @since 0.5.0
735 + * The id arrives from the MODEL's final answer, and model output is
736 + * untrusted — a search turn can be driven by attacker-controlled content, so
737 + * an injected instruction could name an entity the search tools never
738 + * surfaced. Hydration therefore re-checks readability itself instead of
739 + * trusting that the id came out of a filtered tool result: posts/pages go
740 + * through {@see openstation_ai_can_read_post()}, and so does a comment's
741 + * PARENT, because the comment record carries that post's title and permalink
742 + * — approval is a moderation decision, not a visibility one, and an approved
743 + * comment outlives its post being switched to private or back to draft.
744 + * Reading an unapproved comment needs `edit_comment`, mirroring Core's
745 + * `WP_REST_Comments_Controller::check_read_permission()`; the AI moderation
746 + * verdicts and the wp-admin edit link are narrower still. Unreadable ids
747 + * resolve to null, indistinguishable from nonexistent ones.
472 748 *
473 749 * @param string $entity_type 'post' | 'page' | 'comment'.
474 750 * @param int $entity_id
475 751 * @return array|null
476 752 */
477 -function desktop_mode_ai_search_build_entity( $entity_type, $entity_id ) {
753 +function openstation_ai_search_build_entity( $entity_type, $entity_id ) {
478 754 $entity_id = (int) $entity_id;
479 755
480 756 if ( in_array( $entity_type, array( 'post', 'page' ), true ) ) {
481 757 $post = get_post( $entity_id );
482 - if ( ! $post instanceof WP_Post ) {
758 +
759 + // The id must resolve to an actual post or page. The gate below answers
760 + // type visibility on its own, so this is the contract rather than the
761 + // lock: the record's `type` is what the client renders the card from,
762 + // and post/page is what the search tools surface. A viewable CPT row
763 + // would pass the gate and still have no card to land in.
764 + if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
483 765 return null;
484 766 }
767 +
768 + if ( ! openstation_ai_can_read_post( $post ) ) {
769 + return null;
770 + }
771 +
485 772 return array(
486 773 'id' => $entity_id,
487 774 'type' => $post->post_type,
488 - 'title' => wp_strip_all_tags( $post->post_title ),
775 + 'title' => openstation_plain_text_title( $post->post_title ),
489 776 'status' => $post->post_status,
490 777 'date' => $post->post_date ? substr( $post->post_date, 0, 10 ) : '',
491 778 'url' => (string) get_permalink( $post ),
492 779 'edit_url' => (string) get_edit_post_link( $entity_id, 'raw' ),
493 - 'excerpt' => desktop_mode_ai_search_excerpt( $post->post_content ),
780 + 'excerpt' => openstation_ai_search_excerpt( $post->post_content ),
494 781 );
495 782 }
496 783
497 784 if ( 'comment' === $entity_type ) {
498 785 $comment = get_comment( $entity_id );
499 - if ( ! $comment instanceof WP_Comment ) {
786 +
787 + // The parent's reach bounds the comment's: approval is a moderation
788 + // decision, not a visibility one, and this record carries the parent's
789 + // title and permalink — so without this check, naming a comment id
790 + // would walk straight around the post branch's gate above.
791 + if ( ! $comment instanceof WP_Comment || ! openstation_ai_can_read_comment_parent( $comment ) ) {
500 792 return null;
501 793 }
502 - $meta = desktop_mode_ai_get_meta( 'comment', $entity_id );
503 - $parent_post = get_post( $comment->comment_post_ID );
504 - return array(
794 +
795 + // Reading an unapproved comment is an editor's business, per Core's
796 + // WP_REST_Comments_Controller::check_read_permission().
797 + if ( '1' !== (string) $comment->comment_approved && ! current_user_can( 'edit_comment', $entity_id ) ) {
798 + return null;
799 + }
800 +
801 + // The AI verdicts are the moderation queue's data, so they follow the
802 + // moderation capability rather than the per-comment edit one.
803 + $can_moderate = current_user_can( 'moderate_comments' );
804 + $parent_post = get_post( (int) $comment->comment_post_ID );
805 +
806 + $meta = $can_moderate ? openstation_ai_get_meta( 'comment', $entity_id ) : null;
807 + $entity = array(
505 808 'id' => $entity_id,
506 809 'type' => 'comment',
507 - 'excerpt' => desktop_mode_ai_search_excerpt( $comment->comment_content ),
810 + 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
508 811 'post_id' => (int) $comment->comment_post_ID,
509 - 'post_title' => $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '',
510 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
812 + 'post_title' => openstation_plain_text_title( $parent_post->post_title ),
813 + 'post_url' => (string) get_permalink( $parent_post ),
511 814 'url' => (string) get_comment_link( $comment ),
512 - 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . $entity_id ),
513 - 'harmful' => $meta ? (bool) ( $meta['harmful'] ?? false ) : false,
514 - 'spam' => $meta ? (bool) ( $meta['spam'] ?? false ) : false,
815 + 'edit_url' => current_user_can( 'edit_comment', $entity_id )
816 + ? admin_url( 'comment.php?action=editcomment&c=' . $entity_id )
817 + : '',
515 818 );
819 +
820 + // Moderation verdicts are for moderators only.
821 + if ( $can_moderate ) {
822 + $entity['harmful'] = $meta ? (bool) ( $meta['harmful'] ?? false ) : false;
823 + $entity['spam'] = $meta ? (bool) ( $meta['spam'] ?? false ) : false;
824 + }
825 +
826 + return $entity;
516 827 }
517 828
518 829 return null;
519 830 }
@@ -522,49 +833,173 @@
522 833 // Agentic search loop
523 834 // ---------------------------------------------------------------------------
524 835
525 836 /**
526 - * Returns a friendly progress message for a tool name — surfaced to the
527 - * client via SSE so the user sees "Looking through your posts…" rather
528 - * than the raw tool call.
837 + * Returns the label for the "keep looking" button on an exhausted search.
529 838 *
530 - * @since 0.5.0
839 + * One full sentence per resumable tool: a noun interpolated into a shared
840 + * template cannot be translated.
531 841 *
532 - * @param string $tool_name
842 + * @param string $resume_tool Tool the client would resume from.
843 + * @param int $from_item 1-based index of the next item to search.
533 844 * @return string
534 845 */
535 -function desktop_mode_ai_progress_message( $tool_name ) {
536 - switch ( $tool_name ) {
537 - case 'search_posts': return 'Looking through your posts…';
538 - case 'search_pages': return 'Checking your pages…';
539 - case 'search_comments': return 'Reading through comments…';
540 - case 'search_comments_by_post': return 'Scanning comments on that post…';
541 - case 'list_admin_pages': return 'Finding the right admin page…';
542 - case 'search_wporg_plugins': return 'Searching the WordPress.org plugin directory…';
543 - case 'get_php_error_log': return 'Tailing the PHP error log…';
846 +function openstation_ai_continue_label( $resume_tool, $from_item ) {
847 + switch ( $resume_tool ) {
848 + case 'search_pages':
849 + /* translators: %d: 1-based index of the next page to search. */
850 + return sprintf( __( 'Continue searching in pages (from item %d)', 'desktop-mode' ), $from_item );
851 + case 'search_comments':
852 + /* translators: %d: 1-based index of the next comment to search. */
853 + return sprintf( __( 'Continue searching in comments (from item %d)', 'desktop-mode' ), $from_item );
854 + default:
855 + /* translators: %d: 1-based index of the next post to search. */
856 + return sprintf( __( 'Continue searching in posts (from item %d)', 'desktop-mode' ), $from_item );
544 857 }
545 - return 'Thinking…';
546 858 }
547 859
548 860 /**
549 861 * Returns the tools a client may resume an exhausted search from.
550 862 *
551 - * Single source of truth for every `resume_tool` allowlist — the REST
552 - * arg sanitizer, the SSE handler, and the `$initial_tool` validation
553 - * inside `desktop_mode_ai_run_search()`. `search_comments_by_post` is
863 + * Single source of truth for every `resume_tool` allowlist: the REST
864 + * arg sanitizer and the `$initial_tool` validation inside
865 + * `openstation_ai_run_search()`. `search_comments_by_post` is
554 866 * deliberately absent: the `continue` payload carries no `post_id`, so
555 867 * it cannot truly resume — exhausted runs map it to `search_comments`
556 868 * when building the `continue` object.
557 869 *
558 - * @since 0.5.0
559 - *
560 870 * @return string[] Tool names.
561 871 */
562 -function desktop_mode_ai_search_resumable_tools() {
872 +function openstation_ai_search_resumable_tools() {
563 873 return array( 'search_posts', 'search_pages', 'search_comments' );
564 874 }
565 875
566 876 /**
877 + * Projects a tool's `parameters` schema onto the provider-supported subset.
878 + *
879 + * The Abilities API (and plugin-supplied tools) may use the full breadth of JSON
880 + * Schema, but the provider's tool-schema validator does not — and it rejects the
881 + * whole request, not just the offending tool, so ONE tool with a
882 + * legal-but-unsupported schema makes the entire assistant return a 400 before the
883 + * model runs. Three shapes that are valid JSON Schema, but rejected here, have
884 + * been seen in the wild (see the linked issue):
885 + *
886 + * 1. `type` as an array, e.g. ['object','null'] — an ability's GET/null
887 + * run-path. The provider wants the literal string "object" at the top level.
888 + * 2. A top-level `oneOf` / `anyOf` / `allOf`, e.g. "post_id OR slug". Rejected
889 + * with "does not support oneOf, allOf, or anyOf at the top level".
890 + * 3. `properties` as an empty PHP array, which encodes to JSON as `[]` where an
891 + * object schema needs `{}`.
892 + *
893 + * This reshapes only the copy advertised to the model. The ability itself is
894 + * untouched: `WP_Ability::execute()` still validates arguments against the real
895 + * schema and `permission_callback` still gates execution, so nothing loses
896 + * enforcement — the model is simply told the constraint in prose (the tool
897 + * description) instead of in a schema construct the provider can't parse. Only
898 + * the TOP level is constrained; a combinator nested inside a property is a real
899 + * constraint the provider accepts, so it is left intact.
900 + *
901 + * @param mixed $schema A tool parameters schema (array), or empty/non-array.
902 + * @return array A provider-safe object schema for a tool `parameters` block.
903 + */
904 +function openstation_ai_normalize_tool_schema( $schema ) {
905 + if ( ! is_array( $schema ) || empty( $schema ) ) {
906 + return array(
907 + 'type' => 'object',
908 + 'properties' => (object) array(),
909 + );
910 + }
911 +
912 + // Recursively drop the WordPress-only arg-schema keys
913 + // (`sanitize_callback` / `validate_callback` / `arg_options`) —
914 + // see the helper's docblock for why this must run at every depth.
915 + $schema = openstation_ai_strip_wp_schema_keys( $schema );
916 +
917 + // Top-level tool parameters must be the literal "object", never a union.
918 + $schema['type'] = 'object';
919 +
920 + // Strip top-level combinators — the provider rejects them outright, and one
921 + // such tool 400s the whole request. Nested combinators are left alone.
922 + unset( $schema['oneOf'], $schema['allOf'], $schema['anyOf'] );
923 +
924 + // An empty PHP array encodes as `[]`; an object schema's properties need `{}`.
925 + // A schema with no `properties` at all (e.g. one whose only content was a
926 + // stripped top-level combinator) gets an empty object for the same reason.
927 + if ( ! isset( $schema['properties'] ) || array() === $schema['properties'] ) {
928 + $schema['properties'] = (object) array();
929 + }
930 +
931 + return $schema;
932 +}
933 +
934 +/**
935 + * Recursively removes the WordPress-only arg-schema keys from a tool schema.
936 + *
937 + * WordPress arg schemas legally extend JSON Schema with PHP-callable keys —
938 + * `sanitize_callback`, `validate_callback`, and (on meta args) `arg_options`.
939 + * Abilities registered from REST arg definitions carry them at every property
940 + * level, and providers that validate tool schemas strictly reject any unknown
941 + * field ("Invalid JSON payload received. Unknown name \"sanitize_callback\""),
942 + * 400-ing the whole request over one property.
943 + *
944 + * The walk is structure-aware, not a blind key sweep: maps under `properties` /
945 + * `patternProperties` are keyed by PROPERTY NAME, so a property that happens to
946 + * be called `sanitize_callback` is preserved — only its schema value is
947 + * cleaned. Recursion covers every position a subschema can occupy: property
948 + * values, `items` (single schema or tuple list), array-shaped
949 + * `additionalProperties`, and nested `oneOf` / `allOf` / `anyOf` branches
950 + * (which are kept — only the TOP level of the tool schema strips combinators).
951 + *
952 + * @param array $schema A tool parameters (sub)schema.
953 + * @return array The schema without WP-only keys, at any depth.
954 + */
955 +function openstation_ai_strip_wp_schema_keys( array $schema ) {
956 + unset( $schema['sanitize_callback'], $schema['validate_callback'], $schema['arg_options'] );
957 +
958 + foreach ( array( 'properties', 'patternProperties' ) as $map_key ) {
959 + if ( isset( $schema[ $map_key ] ) && is_array( $schema[ $map_key ] ) ) {
960 + foreach ( $schema[ $map_key ] as $name => $sub ) {
961 + if ( is_array( $sub ) ) {
962 + $schema[ $map_key ][ $name ] = openstation_ai_strip_wp_schema_keys( $sub );
963 + }
964 + }
965 + }
966 + }
967 +
968 + if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
969 + $items = $schema['items'];
970 + $is_list = array_keys( $items ) === range( 0, count( $items ) - 1 );
971 + if ( $is_list && array() !== $items ) {
972 + // Tuple form — a list of schemas.
973 + foreach ( $items as $i => $sub ) {
974 + if ( is_array( $sub ) ) {
975 + $items[ $i ] = openstation_ai_strip_wp_schema_keys( $sub );
976 + }
977 + }
978 + $schema['items'] = $items;
979 + } else {
980 + $schema['items'] = openstation_ai_strip_wp_schema_keys( $items );
981 + }
982 + }
983 +
984 + if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
985 + $schema['additionalProperties'] = openstation_ai_strip_wp_schema_keys( $schema['additionalProperties'] );
986 + }
987 +
988 + foreach ( array( 'oneOf', 'allOf', 'anyOf' ) as $combinator ) {
989 + if ( isset( $schema[ $combinator ] ) && is_array( $schema[ $combinator ] ) ) {
990 + foreach ( $schema[ $combinator ] as $i => $sub ) {
991 + if ( is_array( $sub ) ) {
992 + $schema[ $combinator ][ $i ] = openstation_ai_strip_wp_schema_keys( $sub );
993 + }
994 + }
995 + }
996 + }
997 +
998 + return $schema;
999 +}
1000 +
1001 +/**
567 1002 * Runs the agentic content-search loop.
568 1003 *
569 1004 * The model receives focused tools — search_posts, search_pages,
570 1005 * search_comments, search_comments_by_post — and a system prompt that
@@ -576,28 +1011,15 @@
576 1011 * For continuation runs ($initial_tool + $start_offset > 0), the system
577 1012 * message primes the agent to resume from the last searched position with
578 1013 * the same keywords.
579 1014 *
580 - * @since 0.5.0
581 - *
582 1015 * @param string $query User's natural-language search.
583 1016 * @param string|null $initial_tool Tool name to resume from, or null for fresh search.
584 1017 * @param int $start_offset Offset to resume from (0 for fresh).
585 - * @param callable|null $on_progress Optional progress emitter for SSE ticks.
586 1018 * @param array $extra Extensibility context (command tools, prompt overrides, …).
587 1019 * @return array|WP_Error
588 1020 */
589 -function desktop_mode_ai_run_search( $query, $initial_tool = null, $start_offset = 0, $on_progress = null, array $extra = array() ) {
590 - /**
591 - * Progress emitter — sends a tick to the caller if they provided a
592 - * callable; no-op otherwise. Callers use this to render real-time
593 - * status to the user via SSE.
594 - */
595 - $emit = static function ( array $event ) use ( $on_progress ) {
596 - if ( is_callable( $on_progress ) ) {
597 - $on_progress( $event );
598 - }
599 - };
1021 +function openstation_ai_run_search( $query, $initial_tool = null, $start_offset = 0, array $extra = array() ) {
600 1022 $start_offset = max( 0, (int) $start_offset );
601 1023 $search_tools = array( 'search_posts', 'search_pages', 'search_comments', 'search_comments_by_post' );
602 1024 $valid_tools = array_merge(
603 1025 $search_tools,
@@ -609,11 +1031,11 @@
609 1031 // tools from the server-side registry, system-prompt overrides, and a
610 1032 // per-call request_id for observability fanout.
611 1033 // -----------------------------------------------------------------------
612 1034 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
613 - $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && $extra['request_id'] !== ''
1035 + $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && '' !== $extra['request_id']
614 1036 ? (string) $extra['request_id']
615 - : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ) );
1037 + : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ) );
616 1038 $command_tools_raw = isset( $extra['command_tools'] ) && is_array( $extra['command_tools'] ) ? $extra['command_tools'] : array();
617 1039 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
618 1040 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
619 1041 ? (string) $extra['system_prompt_mode']
@@ -621,13 +1043,11 @@
621 1043
622 1044 /**
623 1045 * Fires once per `/ai/search` invocation, after validation and
624 1046 * before any the provider call. First anchor in the observability trio
625 - * (`desktop_mode_ai_search_started` / `desktop_mode_ai_tool_called`
626 - * / `desktop_mode_ai_search_completed`).
1047 + * (`openstation_ai_search_started` / `openstation_ai_tool_called`
1048 + * / `openstation_ai_search_completed`).
627 1049 *
628 - * @since 0.5.1
629 - *
630 1050 * @param array $context {
631 1051 * @type string $query User query.
632 1052 * @type int $user_id
633 1053 * @type string $request_id UUID correlating the whole run.
@@ -633,9 +1053,9 @@
633 1053 * @type string $request_id UUID correlating the whole run.
634 1054 * }
635 1055 */
636 1056 do_action(
637 - 'desktop_mode_ai_search_started',
1057 + 'openstation_ai_search_started',
638 1058 array(
639 1059 'query' => $query,
640 1060 'user_id' => $user_id,
641 1061 'request_id' => $request_id,
@@ -641,9 +1061,9 @@
641 1061 'request_id' => $request_id,
642 1062 )
643 1063 );
644 1064
645 - if ( $initial_tool !== null && ! in_array( $initial_tool, desktop_mode_ai_search_resumable_tools(), true ) ) {
1065 + if ( null !== $initial_tool && ! in_array( $initial_tool, openstation_ai_search_resumable_tools(), true ) ) {
646 1066 $initial_tool = null;
647 1067 }
648 1068
649 1069 // When resuming a previous exhausted run, prime the model with the
@@ -649,9 +1069,9 @@
649 1069 // When resuming a previous exhausted run, prime the model with the
650 1070 // starting position so it doesn't waste iterations on already-searched
651 1071 // content.
652 1072 $continuation_note = '';
653 - if ( $initial_tool !== null && ( $start_offset > 0 || $initial_tool !== 'search_posts' ) ) {
1073 + if ( null !== $initial_tool && ( $start_offset > 0 || 'search_posts' !== $initial_tool ) ) {
654 1074 $continuation_note = sprintf(
655 1075 "\n\nNote: This is a continuation of a previous search. Begin with %s using the same search keywords at offset=%d and work forward.",
656 1076 $initial_tool,
657 1077 $start_offset
@@ -664,22 +1084,21 @@
664 1084 1. **Find content** they've written (posts, pages, comments) by describing it in natural language.
665 1085 2. **Navigate wp-admin** when they ask where to find something (\"where are the categories?\", \"how do I manage users?\").
666 1086 3. **Recommend plugins** from the official WordPress.org directory when they need extra functionality.
667 1087 4. **Check the site's error log** when they're troubleshooting something.
668 -5. **Answer anything else your tools can** — you may have more tools than the ones described below (WordPress and other plugins register their own, e.g. site / user / environment / version info). Your actual tool list is authoritative: whenever a tool can answer the request, call it and summarise the result, even if it isn't in the list below.
1088 +5. **Answer anything else your tools can** — you may have more tools than the ones named here (WordPress and other plugins register their own, e.g. site / user / environment / version info). Your actual tool list is authoritative: whenever a tool can answer the request, call it and summarise the result, even if it isn't named here.
669 1089 6. **Chat** — only when no tool fits, answer conversationally.
670 1090
671 1091 Tone: warm, concise, helpful. First person (\"I found this post…\", \"Here's where you'll find that…\"). Not a search engine tone — no \"Match found\" or robot phrasing.
672 1092
673 -Tools (your actual tool list may include more than these — use any that fit the request):
674 -- search_posts / search_pages / search_comments / search_comments_by_post(post_id, query, offset): keyword content-lookup tools backed by WordPress's native search. Distil the user's description into the essential search keywords and pass them as `query` (e.g. \"that long post about making paella\" → query \"paella\"). Inspect the returned title + excerpt and stop once you find a good match. If has_more is true and nothing matched, call the same tool with next_offset (reuse the same query), or try different keywords. When the query mentions BOTH a post and a comment on that post, call search_posts first to identify the post, THEN search_comments_by_post with the ID. If keyword search returns nothing, broaden or simplify the keywords before giving up.
675 -- list_admin_pages: returns the full catalog of wp-admin destinations. Call once per navigation query, then select the 1-3 most relevant entries.
676 -- search_wporg_plugins(query): searches the official WordPress.org plugin directory. Use when the user asks for a plugin recommendation (\"a plugin for X\", \"is there a plugin that does Y?\"). Returns up to 10 plugins with ratings, install counts, and admin install URLs. Present the best 3-5 as admin_links with titles like \"Plugin Name · 5M+ installs · 4.8★\" (rating is 0-100, divide by 20 to get stars).
677 -- 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.
1093 +How to work the tools (your actual tool list is authoritative; use any tool that fits the request):
1094 +- Content lookups: stop once a returned title and excerpt clearly match. If nothing matched, page on with the next offset or try broader, simpler keywords before telling the user you found nothing.
1095 +- Plugin recommendations: present the best 3-5 as admin_links titled like \"Plugin Name · 5M+ installs · 4.8★\".
1096 +- Error logs: summarise the most important errors first (fatal, then warnings, then notices) instead of copying entries.
678 1097
679 1098 Choosing which track:
680 1099 - \"I remember a post/page/comment about X\" → the corresponding search_* tool.
681 -- \"where can I find X?\" / \"how do I manage Y?\" → list_admin_pages.
1100 +- \"where can I find X?\", \"how do I manage Y?\", \"create/add/new …\", \"take me to …\", \"open …\", \"switch/activate …\", or any navigate/do intent → list_admin_pages, then suggest the 1-3 best destinations as admin_links (answer_type \"navigation\"). You suggest the link; the user opens it — never assume it's opened.
682 1101 - \"plugin for X\" / \"recommend a plugin\" → search_wporg_plugins → present as admin_links.
683 1102 - \"any errors?\" / \"check logs\" / troubleshooting → get_php_error_log → summarise in chat.
684 1103 - Any other factual question about the site (its version, PHP/environment, the current user, or anything one of your other tools covers) → call that tool, then summarise its result with answer_type \"chat\".
685 1104 - Greeting, unclear, or chit-chat → answer_type \"chat\" with a brief helpful message (no tools needed).
@@ -698,9 +1117,9 @@
698 1117
699 1118 // -----------------------------------------------------------------------
700 1119 // System-prompt extensibility. All three layers — appendix filter,
701 1120 // client override (append/replace with capability gate), and final
702 - // transform — live in `desktop_mode_ai_compose_instructions()` so the
1121 + // transform — live in `openstation_ai_compose_instructions()` so the
703 1122 // primary run and the follow-up leg stay in lockstep. See the
704 1123 // helper for the order of application; the filter docblocks at its
705 1124 // `apply_filters()` call sites carry the public contract on each
706 1125 // extension point.
@@ -710,12 +1129,15 @@
710 1129 'user_id' => $user_id,
711 1130 'request_id' => $request_id,
712 1131 );
713 1132
714 - $instructions = desktop_mode_ai_compose_instructions(
1133 + $instructions = openstation_ai_compose_instructions(
715 1134 $instructions,
716 1135 $prompt_context,
717 - array( 'text' => $system_prompt_text, 'mode' => $system_prompt_mode )
1136 + array(
1137 + 'text' => $system_prompt_text,
1138 + 'mode' => $system_prompt_mode,
1139 + )
718 1140 );
719 1141
720 1142 // -----------------------------------------------------------------------
721 1143 // Tool assembly — built-in search/navigation abilities + client-supplied
@@ -729,15 +1151,15 @@
729 1151 // -----------------------------------------------------------------------
730 1152 $ability_by_tool = array();
731 1153 $builtin_tools = array();
732 1154
733 - foreach ( desktop_mode_ai_search_ability_names() as $ability_name ) {
1155 + foreach ( openstation_ai_search_ability_names() as $ability_name ) {
734 1156 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
735 1157 if ( ! $ability instanceof WP_Ability ) {
736 1158 continue;
737 1159 }
738 1160
739 - $tool_name = desktop_mode_ai_ability_tool_name( $ability_name );
1161 + $tool_name = openstation_ai_ability_tool_name( $ability_name );
740 1162 $ability_by_tool[ $tool_name ] = $ability_name;
741 1163 $valid_tools[] = $tool_name;
742 1164
743 1165 $input_schema = $ability->get_input_schema();
@@ -746,9 +1168,12 @@
746 1168 'name' => $tool_name,
747 1169 'description' => (string) $ability->get_description(),
748 1170 'parameters' => ! empty( $input_schema )
749 1171 ? $input_schema
750 - : array( 'type' => 'object', 'properties' => (object) array() ),
1172 + : array(
1173 + 'type' => 'object',
1174 + 'properties' => (object) array(),
1175 + ),
751 1176 );
752 1177 }
753 1178
754 1179 // Command tools — namespaced as `command_<slug>` on the server so
@@ -762,9 +1187,9 @@
762 1187 if ( ! is_array( $cmd ) ) {
763 1188 continue;
764 1189 }
765 1190 $slug = isset( $cmd['slug'] ) ? (string) $cmd['slug'] : '';
766 - if ( $slug === '' || ! preg_match( '/^[a-z0-9_\-]+$/', $slug ) ) {
1191 + if ( '' === $slug || ! preg_match( '/^[a-z0-9_\-]+$/', $slug ) ) {
767 1192 continue;
768 1193 }
769 1194 /**
770 1195 * Per-tool filter on the client-supplied command list. Return
@@ -770,10 +1195,8 @@
770 1195 * Per-tool filter on the client-supplied command list. Return
771 1196 * `false` to drop a command entirely before it reaches the
772 1197 * model — the right hook for per-role / per-command gating.
773 1198 *
774 - * @since 0.5.1
775 - *
776 1199 * @param bool|array $allowed Either the (possibly mutated) command
777 1200 * tool entry, or `false` to drop it.
778 1201 * @param string $slug Command slug.
779 1202 * @param array $context { user_id, request_id }.
@@ -778,12 +1201,15 @@
778 1201 * @param string $slug Command slug.
779 1202 * @param array $context { user_id, request_id }.
780 1203 */
781 1204 $allowed = apply_filters(
782 - 'desktop_mode_ai_command_allowed',
1205 + 'openstation_ai_command_allowed',
783 1206 $cmd,
784 1207 $slug,
785 - array( 'user_id' => $user_id, 'request_id' => $request_id )
1208 + array(
1209 + 'user_id' => $user_id,
1210 + 'request_id' => $request_id,
1211 + )
786 1212 );
787 1213 if ( false === $allowed || ! is_array( $allowed ) ) {
788 1214 continue;
789 1215 }
@@ -797,13 +1223,13 @@
797 1223 'type' => 'function',
798 1224 'name' => $tool_name,
799 1225 'description' => trim( $label . ( '' !== $description ? ' — ' . $description : '' ) ),
800 1226 'parameters' => array(
801 - 'type' => 'object',
802 - 'properties' => array(
1227 + 'type' => 'object',
1228 + 'properties' => array(
803 1229 'args' => array(
804 1230 'type' => 'string',
805 - 'description' => $hint !== ''
1231 + 'description' => '' !== $hint
806 1232 ? sprintf( 'Arguments for this command. Hint: %s', $hint )
807 1233 : 'Arguments for this command. Leave empty when the command takes none.',
808 1234 ),
809 1235 ),
@@ -817,17 +1243,18 @@
817 1243 * Transform the command-tool subset before merging with the
818 1244 * built-in + registered tools. Useful for bulk gating, renaming,
819 1245 * or injecting synthetic command tools.
820 1246 *
821 - * @since 0.5.1
822 - *
823 1247 * @param array $command_defs Command tool definitions.
824 1248 * @param array $context { user_id, request_id }.
825 1249 */
826 1250 $command_defs = (array) apply_filters(
827 - 'desktop_mode_ai_command_tools',
1251 + 'openstation_ai_command_tools',
828 1252 $command_defs,
829 - array( 'user_id' => $user_id, 'request_id' => $request_id )
1253 + array(
1254 + 'user_id' => $user_id,
1255 + 'request_id' => $request_id,
1256 + )
830 1257 );
831 1258
832 1259 $tools = array_merge( $builtin_tools, $command_defs );
833 1260
@@ -835,19 +1262,34 @@
835 1262 * Transform the full tool list (built-in abilities + command tools) just
836 1263 * before it goes to the provider. Fires once per run — changes apply to
837 1264 * every iteration in the agent loop.
838 1265 *
839 - * @since 0.5.1
840 - *
841 1266 * @param array $tools Full the provider tool definitions array.
842 1267 * @param array $context { user_id, request_id, query }.
843 1268 */
844 1269 $tools = (array) apply_filters(
845 - 'desktop_mode_ai_tools',
1270 + 'openstation_ai_tools',
846 1271 $tools,
847 - array( 'user_id' => $user_id, 'request_id' => $request_id, 'query' => $query )
1272 + array(
1273 + 'user_id' => $user_id,
1274 + 'request_id' => $request_id,
1275 + 'query' => $query,
1276 + )
848 1277 );
849 1278
1279 + // Normalize every tool's schema onto the provider-supported subset, AFTER the
1280 + // filter so it covers the complete list the provider will receive — built-in
1281 + // abilities, command tools, and anything a plugin injected. One tool with a
1282 + // legal-but-unsupported schema otherwise 400s the entire request, not just its
1283 + // own tool. Only the model-facing copy is reshaped; abilities still validate
1284 + // arguments against their real schema in execute(). Idempotent, so a plugin
1285 + // that already normalizes on the filter above is unaffected.
1286 + foreach ( $tools as $ti => $tool ) {
1287 + if ( is_array( $tool ) && isset( $tool['parameters'] ) ) {
1288 + $tools[ $ti ]['parameters'] = openstation_ai_normalize_tool_schema( $tool['parameters'] );
1289 + }
1290 + }
1291 +
850 1292 // Widen the permitted-tools list with the command tools — the agent loop
851 1293 // rejects any `function_call` whose name isn't in here (built-in ability
852 1294 // names were added above).
853 1295 foreach ( $command_defs as $def ) {
@@ -855,12 +1297,10 @@
855 1297 $valid_tools[] = (string) $def['name'];
856 1298 }
857 1299 }
858 1300
859 - $answer_schema = desktop_mode_ai_search_answer_schema();
1301 + $answer_schema = openstation_ai_search_answer_schema();
860 1302
861 - $emit( array( 'phase' => 'start', 'message' => 'Thinking about your question…' ) );
862 -
863 1303 // -----------------------------------------------------------------------
864 1304 // First call — user query as the sole message, instructions as system
865 1305 // guidance. Generation routes through the WordPress AI Client; the tools
866 1306 // are advertised as function declarations and dispatched by this loop.
@@ -865,12 +1305,17 @@
865 1305 // guidance. Generation routes through the WordPress AI Client; the tools
866 1306 // are advertised as function declarations and dispatched by this loop.
867 1307 // The full ordered conversation is rebuilt and re-sent each turn.
868 1308 // -----------------------------------------------------------------------
869 - $messages = array( desktop_mode_ai_user_text_message( $query ) );
1309 + $messages = array( openstation_ai_user_text_message( $query ) );
870 1310
871 - $turn = desktop_mode_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
1311 + $generation_context = array(
1312 + 'source' => 'ai-copilot/search',
1313 + 'request_id' => $request_id,
1314 + );
872 1315
1316 + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1317 +
873 1318 if ( is_wp_error( $turn ) ) {
874 1319 return $turn;
875 1320 }
876 1321
@@ -879,10 +1324,14 @@
879 1324 $last_has_more = true;
880 1325 $iterations = 0;
881 1326
882 1327 // Accumulate token usage across every turn and remember the last model the
883 - // AI Client resolved, for the `desktop_mode_ai_search_completed` payload.
884 - $total_usage = array( 'prompt' => 0, 'completion' => 0, 'total' => 0 );
1328 + // AI Client resolved, for the `openstation_ai_search_completed` payload.
1329 + $total_usage = array(
1330 + 'prompt' => 0,
1331 + 'completion' => 0,
1332 + 'total' => 0,
1333 + );
885 1334 $last_model = null;
886 1335 $accrue_usage = static function ( $turn ) use ( &$total_usage, &$last_model ) {
887 1336 if ( ! is_array( $turn ) ) {
888 1337 return;
@@ -902,27 +1351,22 @@
902 1351 // Agentic loop — each iteration either executes tool calls or returns
903 1352 // the final answer. The full ordered conversation (user query, assistant
904 1353 // turns, tool results) is accumulated in $messages and re-sent each turn.
905 1354 // -----------------------------------------------------------------------
906 - for ( $i = 0; $i < DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS; $i++ ) {
1355 + for ( $i = 0; $i < OPENSTATION_AI_SEARCH_MAX_ITERATIONS; $i++ ) {
907 1356 $function_calls = is_array( $turn['function_calls'] ?? null ) ? $turn['function_calls'] : array();
908 1357
909 1358 // No tool calls in this response → final answer.
910 1359 if ( empty( $function_calls ) ) {
911 - $emit( array( 'phase' => 'composing', 'message' => 'Putting together your answer…' ) );
912 - $text = $turn['text'] ?? null;
913 - if ( ! is_string( $text ) ) {
914 - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
915 - error_log( '[WP Desktop Mode AI] AI Client returned no text in the final turn.' );
916 - return new WP_Error(
917 - 'desktop_mode_ai_empty',
918 - 'The AI provider returned no text in the final turn.'
919 - );
920 - }
1360 + // A toolless turn with no extractable text never reaches here:
1361 + // openstation_ai_client_generate() returns
1362 + // `openstation_ai_empty_answer` for that case, handled with the
1363 + // other generation errors above.
1364 + $text = (string) ( $turn['text'] ?? '' );
921 1365
922 1366 $answer = json_decode( $text, true );
923 1367 if ( ! is_array( $answer ) ) {
924 - return new WP_Error( 'desktop_mode_ai_result_parse', 'Could not parse structured search answer.' );
1368 + return new WP_Error( 'openstation_ai_result_parse', __( 'Could not parse structured search answer.', 'desktop-mode' ) );
925 1369 }
926 1370
927 1371 $answer_type = isset( $answer['answer_type'] ) && in_array( $answer['answer_type'], array( 'entity', 'navigation', 'chat' ), true )
928 1372 ? (string) $answer['answer_type']
@@ -936,9 +1380,9 @@
936 1380 ? $answer['admin_links'] : null;
937 1381
938 1382 $entity = null;
939 1383 if ( 'entity' === $answer_type && $entity_id && $entity_type ) {
940 - $entity = desktop_mode_ai_search_build_entity( $entity_type, $entity_id );
1384 + $entity = openstation_ai_search_build_entity( $entity_type, $entity_id );
941 1385 }
942 1386
943 1387 $final = array(
944 1388 'answer_type' => $answer_type,
@@ -955,21 +1399,23 @@
955 1399 * Final transform hook — fires right before the HTTP
956 1400 * response is returned. Plugins can rewrite `message`,
957 1401 * inject `admin_links`, coerce `answer_type`, etc.
958 1402 *
959 - * @since 0.5.1
960 - *
961 1403 * @param array $answer Final answer payload.
962 1404 * @param array $context { query, user_id, request_id }.
963 1405 */
964 1406 $final = (array) apply_filters(
965 - 'desktop_mode_ai_answer',
1407 + 'openstation_ai_answer',
966 1408 $final,
967 - array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1409 + array(
1410 + 'query' => $query,
1411 + 'user_id' => $user_id,
1412 + 'request_id' => $request_id,
1413 + )
968 1414 );
969 1415
970 1416 do_action(
971 - 'desktop_mode_ai_search_completed',
1417 + 'openstation_ai_search_completed',
972 1418 array(
973 1419 'query' => $query,
974 1420 'user_id' => $user_id,
975 1421 'request_id' => $request_id,
@@ -995,10 +1441,10 @@
995 1441 $command_tool_call = null;
996 1442 foreach ( $function_calls as $fc ) {
997 1443 $name = (string) ( $fc['name'] ?? '' );
998 1444 if ( isset( $command_tools_by_name[ $name ] ) ) {
999 - $raw = json_decode( $fc['arguments'] ?? '{}', true );
1000 - $decoded = is_array( $raw ) ? $raw : array();
1445 + $raw = json_decode( $fc['arguments'] ?? '{}', true );
1446 + $decoded = is_array( $raw ) ? $raw : array();
1001 1447 $command_tool_call = array(
1002 1448 'slug' => $command_tools_by_name[ $name ]['slug'],
1003 1449 'args' => isset( $decoded['args'] ) ? (string) $decoded['args'] : '',
1004 1450 );
@@ -1004,11 +1450,11 @@
1004 1450 );
1005 1451 break;
1006 1452 }
1007 1453 }
1008 - if ( $command_tool_call !== null ) {
1454 + if ( null !== $command_tool_call ) {
1009 1455 do_action(
1010 - 'desktop_mode_ai_tool_called',
1456 + 'openstation_ai_tool_called',
1011 1457 array(
1012 1458 'tool_name' => 'command_' . $command_tool_call['slug'],
1013 1459 'args' => array( 'args' => $command_tool_call['args'] ),
1014 1460 'user_id' => $user_id,
@@ -1028,15 +1474,19 @@
1028 1474 'request_id' => $request_id,
1029 1475 );
1030 1476
1031 1477 $final = (array) apply_filters(
1032 - 'desktop_mode_ai_answer',
1478 + 'openstation_ai_answer',
1033 1479 $final,
1034 - array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1480 + array(
1481 + 'query' => $query,
1482 + 'user_id' => $user_id,
1483 + 'request_id' => $request_id,
1484 + )
1035 1485 );
1036 1486
1037 1487 do_action(
1038 - 'desktop_mode_ai_search_completed',
1488 + 'openstation_ai_search_completed',
1039 1489 array(
1040 1490 'query' => $query,
1041 1491 'user_id' => $user_id,
1042 1492 'request_id' => $request_id,
@@ -1051,9 +1501,9 @@
1051 1501 }
1052 1502
1053 1503 // Execute each tool call and collect results as
1054 1504 // `{ call_id, name, response }` — turned into FunctionResponse parts
1055 - // for the next turn by desktop_mode_ai_tool_result_message().
1505 + // for the next turn by openstation_ai_tool_result_message().
1056 1506 $tool_outputs = array();
1057 1507 foreach ( $function_calls as $fc ) {
1058 1508 $tool_name = $fc['name'] ?? '';
1059 1509 $call_id = $fc['call_id'] ?? '';
@@ -1070,16 +1520,10 @@
1070 1520 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1071 1521 $args = is_array( $raw ) ? $raw : array();
1072 1522 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
1073 1523
1074 - $emit( array(
1075 - 'phase' => 'tool_call',
1076 - 'tool' => $tool_name,
1077 - 'message' => desktop_mode_ai_progress_message( $tool_name ),
1078 - ) );
1079 -
1080 1524 do_action(
1081 - 'desktop_mode_ai_tool_called',
1525 + 'openstation_ai_tool_called',
1082 1526 array(
1083 1527 'tool_name' => $tool_name,
1084 1528 'args' => $args,
1085 1529 'user_id' => $user_id,
@@ -1098,14 +1542,14 @@
1098 1542 // there's no schema; otherwise hand over the decoded args.
1099 1543 $input = empty( $ability->get_input_schema() ) ? null : $args;
1100 1544 $result = $ability->execute( $input );
1101 1545 } else {
1102 - $result = new WP_Error( 'desktop_mode_ai_unknown_ability', sprintf( 'Ability for tool "%s" is unavailable.', $tool_name ) );
1546 + $result = new WP_Error( 'openstation_ai_unknown_ability', sprintf( 'Ability for tool "%s" is unavailable.', $tool_name ) );
1103 1547 }
1104 1548
1105 1549 if ( is_wp_error( $result ) ) {
1106 1550 do_action(
1107 - 'desktop_mode_ai_search_error',
1551 + 'openstation_ai_search_error',
1108 1552 array(
1109 1553 'stage' => 'tool_execute',
1110 1554 'tool_name' => $tool_name,
1111 1555 'error' => $result->get_error_code(),
@@ -1130,10 +1574,8 @@
1130 1574 * Transform a tool result before it goes back to the model.
1131 1575 * Fires for every ability-dispatched tool (including error
1132 1576 * envelopes from a failed execute()).
1133 1577 *
1134 - * @since 0.5.1
1135 - *
1136 1578 * @param array $batch Tool result payload.
1137 1579 * @param string $tool_name Tool function name.
1138 1580 * @param array $args Decoded args from the call.
1139 1581 * @param array $context { user_id, request_id }.
@@ -1138,13 +1580,16 @@
1138 1580 * @param array $args Decoded args from the call.
1139 1581 * @param array $context { user_id, request_id }.
1140 1582 */
1141 1583 $batch = (array) apply_filters(
1142 - 'desktop_mode_ai_tool_result',
1584 + 'openstation_ai_tool_result',
1143 1585 $batch,
1144 1586 $tool_name,
1145 1587 $args,
1146 - array( 'user_id' => $user_id, 'request_id' => $request_id )
1588 + array(
1589 + 'user_id' => $user_id,
1590 + 'request_id' => $request_id,
1591 + )
1147 1592 );
1148 1593
1149 1594 $tool_outputs[] = array(
1150 1595 'call_id' => $call_id,
@@ -1152,16 +1597,16 @@
1152 1597 'response' => $batch,
1153 1598 );
1154 1599 }
1155 1600
1156 - $iterations++;
1601 + ++$iterations;
1157 1602
1158 1603 // Next turn — append the assistant's tool-call turn and our tool
1159 1604 // results to the conversation, then regenerate with the full history.
1160 1605 $messages[] = $turn['message'];
1161 - $messages[] = desktop_mode_ai_tool_result_message( $tool_outputs );
1606 + $messages[] = openstation_ai_tool_result_message( $tool_outputs );
1162 1607
1163 - $turn = desktop_mode_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
1608 + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1164 1609
1165 1610 if ( is_wp_error( $turn ) ) {
1166 1611 return $turn;
1167 1612 }
@@ -1172,28 +1617,27 @@
1172 1617 // Budget exhausted before a final answer.
1173 1618 // -----------------------------------------------------------------------
1174 1619 $continue = null;
1175 1620 if ( $last_has_more ) {
1176 - $next_offset = $last_offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE;
1621 + $next_offset = $last_offset + OPENSTATION_AI_SEARCH_BATCH_SIZE;
1177 1622 // `search_comments_by_post` cannot resume — the continue payload
1178 1623 // carries no post_id — so fall back to plain comment search,
1179 - // keeping `tool` inside desktop_mode_ai_search_resumable_tools().
1624 + // keeping `tool` inside openstation_ai_search_resumable_tools().
1180 1625 $resume_tool = 'search_comments_by_post' === $last_tool ? 'search_comments' : $last_tool;
1181 - $type_label = str_replace( 'search_', '', $resume_tool ) . 's';
1182 1626 $continue = array(
1183 1627 'tool' => $resume_tool,
1184 1628 'entity_type' => rtrim( str_replace( 'search_', '', $resume_tool ), 's' ),
1185 1629 'offset' => $next_offset,
1186 - 'label' => sprintf( 'Continue searching in %s (from item %d)', $type_label, $next_offset + 1 ),
1630 + 'label' => openstation_ai_continue_label( $resume_tool, $next_offset + 1 ),
1187 1631 );
1188 1632 }
1189 1633
1190 1634 $final = array(
1191 1635 'answer_type' => 'chat',
1192 - 'message' => 'I searched 100 items without finding a clear match. Want me to keep looking further?',
1636 + 'message' => __( 'I searched 100 items without finding a clear match. Want me to keep looking further?', 'desktop-mode' ),
1193 1637 'entity' => null,
1194 1638 'admin_links' => null,
1195 - 'iterations' => DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS,
1639 + 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1196 1640 'exhausted' => ! $last_has_more,
1197 1641 'continue' => $continue,
1198 1642 'request_id' => $request_id,
1199 1643 );
@@ -1198,21 +1642,25 @@
1198 1642 'request_id' => $request_id,
1199 1643 );
1200 1644
1201 1645 $final = (array) apply_filters(
1202 - 'desktop_mode_ai_answer',
1646 + 'openstation_ai_answer',
1203 1647 $final,
1204 - array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1648 + array(
1649 + 'query' => $query,
1650 + 'user_id' => $user_id,
1651 + 'request_id' => $request_id,
1652 + )
1205 1653 );
1206 1654
1207 1655 do_action(
1208 - 'desktop_mode_ai_search_completed',
1656 + 'openstation_ai_search_completed',
1209 1657 array(
1210 1658 'query' => $query,
1211 1659 'user_id' => $user_id,
1212 1660 'request_id' => $request_id,
1213 1661 'answer_type' => 'chat',
1214 - 'iterations' => DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS,
1662 + 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1215 1663 'usage' => $total_usage,
1216 1664 'model' => $last_model,
1217 1665 )
1218 1666 );
@@ -1227,18 +1675,17 @@
1227 1675 * keeps the voice consistent across legs and removes a class of drift
1228 1676 * bug where the two paths's prompt-assembly drifts apart.
1229 1677 *
1230 1678 * Applies three layers in order:
1231 - * 1. `desktop_mode_ai_system_prompt_appendix` — stacking filter;
1679 + * 1. `openstation_ai_system_prompt_appendix` — stacking filter;
1232 1680 * every plugin's return is concatenated.
1233 1681 * 2. Client override — `system_prompt_text` + `system_prompt_mode`.
1234 1682 * `append` always allowed; `replace` gated on
1235 - * `desktop_mode_ai_system_prompt_replace_capability`. Non-permitted
1683 + * `openstation_ai_system_prompt_replace_capability`. Non-permitted
1236 1684 * `replace` downgrades to `append` so the caller's text is
1237 1685 * preserved rather than dropped.
1238 - * 3. `desktop_mode_ai_system_prompt` — final transform pass.
1686 + * 3. `openstation_ai_system_prompt` — final transform pass.
1239 1687 *
1240 - * @since 0.5.1
1241 1688 * @internal
1242 1689 *
1243 1690 * @param string $core Built-in instructions for this phase
1244 1691 * (agent loop / follow-up summariser).
@@ -1246,9 +1693,9 @@
1246 1693 * @param array $client { text, mode } client override; either field
1247 1694 * empty means no override.
1248 1695 * @return string Composed system prompt.
1249 1696 */
1250 -function desktop_mode_ai_compose_instructions( $core, array $context, array $client = array() ) {
1697 +function openstation_ai_compose_instructions( $core, array $context, array $client = array() ) {
1251 1698 $instructions = (string) $core;
1252 1699 $user_id = isset( $context['user_id'] ) ? (int) $context['user_id'] : 0;
1253 1700
1254 1701 $client_text = isset( $client['text'] ) && is_string( $client['text'] ) ? $client['text'] : '';
@@ -1255,9 +1702,9 @@
1255 1702 $client_mode = isset( $client['mode'] ) && in_array( $client['mode'], array( 'append', 'replace' ), true )
1256 1703 ? (string) $client['mode']
1257 1704 : 'append';
1258 1705
1259 - $ctx_for_filter = $context;
1706 + $ctx_for_filter = $context;
1260 1707 $ctx_for_filter['client_override'] = '' !== $client_text ? $client_mode : null;
1261 1708
1262 1709 /**
1263 1710 * Short-circuit extension — appended to the built-in instructions
@@ -1265,14 +1712,12 @@
1265 1712 * context (room list, product catalogue, company jargon) without
1266 1713 * restructuring the core rules. Fires for both the primary
1267 1714 * `/ai/search` run and the follow-up composed-reply leg.
1268 1715 *
1269 - * @since 0.5.1
1270 - *
1271 1716 * @param string $appendix Accumulated appendix. Default empty.
1272 1717 * @param array $context { query, user_id, request_id, client_override, phase? }.
1273 1718 */
1274 - $server_appendix = (string) apply_filters( 'desktop_mode_ai_system_prompt_appendix', '', $ctx_for_filter );
1719 + $server_appendix = (string) apply_filters( 'openstation_ai_system_prompt_appendix', '', $ctx_for_filter );
1275 1720 if ( '' !== $server_appendix ) {
1276 1721 $instructions .= "\n\n" . $server_appendix;
1277 1722 }
1278 1723
@@ -1283,15 +1728,13 @@
1283 1728 * `system_prompt: { mode: 'replace' }`. Defaults to `manage_options`
1284 1729 * — replacing the whole prompt can effectively hijack the
1285 1730 * assistant, so it's admin-only out of the box.
1286 1731 *
1287 - * @since 0.5.1
1288 - *
1289 1732 * @param string $capability Default `manage_options`.
1290 1733 * @param array $context
1291 1734 */
1292 1735 $required_cap = (string) apply_filters(
1293 - 'desktop_mode_ai_system_prompt_replace_capability',
1736 + 'openstation_ai_system_prompt_replace_capability',
1294 1737 'manage_options',
1295 1738 $ctx_for_filter
1296 1739 );
1297 1740 if ( '' === $required_cap || ( $user_id > 0 && user_can( $user_id, $required_cap ) ) ) {
@@ -1309,14 +1752,12 @@
1309 1752 /**
1310 1753 * Final transform pass. Fires after the built-in instructions,
1311 1754 * server appendix, and client override have all been composed.
1312 1755 *
1313 - * @since 0.5.1
1314 - *
1315 1756 * @param string $instructions Composed system prompt.
1316 1757 * @param array $context
1317 1758 */
1318 - return (string) apply_filters( 'desktop_mode_ai_system_prompt', $instructions, $ctx_for_filter );
1759 + return (string) apply_filters( 'openstation_ai_system_prompt', $instructions, $ctx_for_filter );
1319 1760 }
1320 1761
1321 1762 /**
1322 1763 * Compose a natural-language reply describing the outcome of a
@@ -1323,37 +1764,35 @@
1323 1764 * client-dispatched command invocation.
1324 1765 *
1325 1766 * Called by the REST endpoint when the client sends `follow_up` —
1326 1767 * the second leg of the opt-in agentic flow triggered by
1327 - * `wp.desktop.ai.ask( q, { tools: 'aiCallable', followUp: true } )`.
1768 + * `wp.os.ai.ask( q, { tools: 'aiCallable', followUp: true } )`.
1328 1769 *
1329 1770 * Single-turn, no tools, no structured-output schema — the model
1330 1771 * sees the original query + a summary of what happened and writes a
1331 1772 * one/two-sentence reply in the voice of the system prompt. We reuse
1332 1773 * the same system-prompt pipeline as the main search so plugins
1333 - * appending instructions via `desktop_mode_ai_system_prompt_appendix`
1774 + * appending instructions via `openstation_ai_system_prompt_appendix`
1334 1775 * see consistent voice across the two legs.
1335 1776 *
1336 - * @since 0.5.1
1337 - *
1338 1777 * @param string $query Original user query.
1339 1778 * @param array $tool { slug, args } — what ran.
1340 1779 * @param array $outcome Tool result payload. Opaque — JSON-encoded
1341 1780 * into the model's context so it can reason
1342 1781 * about whatever shape the plugin returned.
1343 - * @param array $extra Same shape as `desktop_mode_ai_run_search`'s
1782 + * @param array $extra Same shape as `openstation_ai_run_search`'s
1344 1783 * `$extra` — carries user_id, request_id,
1345 1784 * system-prompt overrides.
1346 1785 * @return array|WP_Error `{ answer_type: 'chat', message, … }` or error.
1347 1786 */
1348 -function desktop_mode_ai_run_followup( $query, array $tool, array $outcome, array $extra = array() ) {
1787 +function openstation_ai_run_followup( $query, array $tool, array $outcome, array $extra = array() ) {
1349 1788 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
1350 - $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && $extra['request_id'] !== ''
1789 + $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && '' !== $extra['request_id']
1351 1790 ? (string) $extra['request_id']
1352 - : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ) );
1791 + : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ) );
1353 1792
1354 1793 do_action(
1355 - 'desktop_mode_ai_search_started',
1794 + 'openstation_ai_search_started',
1356 1795 array(
1357 1796 'query' => $query,
1358 1797 'user_id' => $user_id,
1359 1798 'request_id' => $request_id,
@@ -1363,19 +1802,19 @@
1363 1802
1364 1803 // Mirror the main search's system-prompt layering so voice stays
1365 1804 // consistent between the two legs. We build a simpler core-
1366 1805 // instructions block — no tool guidance, since this run has none.
1367 - $instructions = "
1806 + $instructions = '
1368 1807 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.
1369 1808
1370 -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.
1809 +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.
1371 1810
1372 1811 Rules:
1373 -- If the outcome looks successful, confirm plainly. Example: \"Done — your office light is on now.\"
1812 +- If the outcome looks successful, confirm plainly. Example: "Done — your office light is on now."
1374 1813 - 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.
1375 -- Do NOT recommend the user try something else unless the outcome explicitly suggests it.
1376 -- Do NOT describe the tool mechanism (\"I called command_turn_light\") — the user only cares about the real-world effect.
1377 -";
1814 +- Suggest a next step only when the outcome itself suggests one.
1815 +- Describe the real-world effect, not the tool mechanism ("I called command_turn_light").
1816 +';
1378 1817
1379 1818 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
1380 1819 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1381 1820 ? (string) $extra['system_prompt_mode']
@@ -1380,9 +1819,9 @@
1380 1819 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1381 1820 ? (string) $extra['system_prompt_mode']
1382 1821 : 'append';
1383 1822
1384 - $instructions = desktop_mode_ai_compose_instructions(
1823 + $instructions = openstation_ai_compose_instructions(
1385 1824 $instructions,
1386 1825 array(
1387 1826 'query' => $query,
1388 1827 'user_id' => $user_id,
@@ -1388,9 +1827,12 @@
1388 1827 'user_id' => $user_id,
1389 1828 'request_id' => $request_id,
1390 1829 'phase' => 'follow_up',
1391 1830 ),
1392 - array( 'text' => $system_prompt_text, 'mode' => $system_prompt_mode )
1831 + array(
1832 + 'text' => $system_prompt_text,
1833 + 'mode' => $system_prompt_mode,
1834 + )
1393 1835 );
1394 1836
1395 1837 $slug = isset( $tool['slug'] ) ? (string) $tool['slug'] : '';
1396 1838 $tool_args = isset( $tool['args'] ) ? (string) $tool['args'] : '';
@@ -1409,9 +1851,9 @@
1409 1851 // (Japanese / emoji / accented UTF-8) can't produce invalid JSON
1410 1852 // that the provider would reject. Falls back to byte-level substr when
1411 1853 // mbstring is unavailable (rare but possible on minimal PHP
1412 1854 // builds).
1413 - $max_outcome_len = (int) apply_filters( 'desktop_mode_ai_followup_outcome_max_chars', 4000 );
1855 + $max_outcome_len = (int) apply_filters( 'openstation_ai_followup_outcome_max_chars', 4000 );
1414 1856 if ( $max_outcome_len > 0 ) {
1415 1857 $has_mbstring = function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' );
1416 1858 $current_len = $has_mbstring
1417 1859 ? mb_strlen( $outcome_json, 'UTF-8' )
@@ -1416,9 +1858,9 @@
1416 1858 $current_len = $has_mbstring
1417 1859 ? mb_strlen( $outcome_json, 'UTF-8' )
1418 1860 : strlen( $outcome_json );
1419 1861 if ( $current_len > $max_outcome_len ) {
1420 - $outcome_json = $has_mbstring
1862 + $outcome_json = $has_mbstring
1421 1863 ? mb_substr( $outcome_json, 0, $max_outcome_len, 'UTF-8' )
1422 1864 : substr( $outcome_json, 0, $max_outcome_len );
1423 1865 $outcome_json .= '…[truncated]';
1424 1866 }
@@ -1432,28 +1874,41 @@
1432 1874 $outcome_json
1433 1875 );
1434 1876
1435 1877 do_action(
1436 - 'desktop_mode_ai_tool_called',
1878 + 'openstation_ai_tool_called',
1437 1879 array(
1438 1880 'tool_name' => 'followup_summarise',
1439 - 'args' => array( 'slug' => $slug, 'tool_args' => $tool_args ),
1881 + 'args' => array(
1882 + 'slug' => $slug,
1883 + 'tool_args' => $tool_args,
1884 + ),
1440 1885 'user_id' => $user_id,
1441 1886 'request_id' => $request_id,
1442 1887 )
1443 1888 );
1444 1889
1445 - $turn = desktop_mode_ai_client_generate(
1890 + $turn = openstation_ai_client_generate(
1446 1891 $user_id,
1447 - array( desktop_mode_ai_user_text_message( $user_message ) ),
1892 + array( openstation_ai_user_text_message( $user_message ) ),
1448 1893 array(), // no tools — we want a plain reply
1449 1894 null, // no JSON schema — free-form text
1450 - $instructions
1895 + $instructions,
1896 + array(
1897 + 'source' => 'ai-copilot/followup',
1898 + 'request_id' => $request_id,
1899 + )
1451 1900 );
1452 1901
1453 - if ( is_wp_error( $turn ) ) {
1902 + // `openstation_ai_empty_answer` is the one generation error this path
1903 + // deliberately absorbs: the command DID run, so a text-less summary turn
1904 + // degrades to the generic confirmation below instead of surfacing as a
1905 + // failure of the command itself.
1906 + $empty_answer = is_wp_error( $turn ) && 'openstation_ai_empty_answer' === $turn->get_error_code();
1907 +
1908 + if ( is_wp_error( $turn ) && ! $empty_answer ) {
1454 1909 do_action(
1455 - 'desktop_mode_ai_search_error',
1910 + 'openstation_ai_search_error',
1456 1911 array(
1457 1912 'code' => $turn->get_error_code(),
1458 1913 'message' => $turn->get_error_message(),
1459 1914 'data' => $turn->get_error_data(),
@@ -1464,9 +1919,9 @@
1464 1919 );
1465 1920 return $turn;
1466 1921 }
1467 1922
1468 - $text = $turn['text'] ?? null;
1923 + $text = $empty_answer ? null : ( $turn['text'] ?? null );
1469 1924 $fallback = false;
1470 1925 if ( ! is_string( $text ) || '' === trim( $text ) ) {
1471 1926 // Graceful degrade — if the provider returned nothing usable, fall
1472 1927 // back to a generic confirmation so the caller always has a
@@ -1486,14 +1941,17 @@
1486 1941 'iterations' => 1,
1487 1942 'exhausted' => false,
1488 1943 'continue' => null,
1489 1944 'request_id' => $request_id,
1490 - 'tool' => array( 'slug' => $slug, 'args' => $tool_args ),
1945 + 'tool' => array(
1946 + 'slug' => $slug,
1947 + 'args' => $tool_args,
1948 + ),
1491 1949 'fallback' => $fallback,
1492 1950 );
1493 1951
1494 1952 $final = (array) apply_filters(
1495 - 'desktop_mode_ai_answer',
1953 + 'openstation_ai_answer',
1496 1954 $final,
1497 1955 array(
1498 1956 'query' => $query,
1499 1957 'user_id' => $user_id,
@@ -1502,9 +1960,9 @@
1502 1960 )
1503 1961 );
1504 1962
1505 1963 do_action(
1506 - 'desktop_mode_ai_search_completed',
1964 + 'openstation_ai_search_completed',
1507 1965 array(
1508 1966 'query' => $query,
1509 1967 'user_id' => $user_id,
1510 1968 'request_id' => $request_id,
@@ -1523,21 +1981,19 @@
1523 1981 // ---------------------------------------------------------------------------
1524 1982
1525 1983 /**
1526 1984 * Registers the AI search REST route.
1527 - *
1528 - * @since 0.5.0
1529 1985 */
1530 -function desktop_mode_register_ai_search_rest_route() {
1986 +function openstation_register_ai_search_rest_route() {
1531 1987 register_rest_route(
1532 1988 'desktop-mode/v1',
1533 1989 '/ai/search',
1534 1990 array(
1535 1991 'methods' => WP_REST_Server::CREATABLE,
1536 - 'callback' => 'desktop_mode_rest_ai_search',
1537 - 'permission_callback' => 'desktop_mode_rest_ai_search_permission',
1992 + 'callback' => 'openstation_rest_ai_search',
1993 + 'permission_callback' => 'openstation_rest_ai_search_permission',
1538 1994 'args' => array(
1539 - 'query' => array(
1995 + 'query' => array(
1540 1996 'required' => true,
1541 1997 'type' => 'string',
1542 1998 'sanitize_callback' => 'sanitize_text_field',
1543 1999 'validate_callback' => static function ( $v ) {
@@ -1547,18 +2003,18 @@
1547 2003 // `resume_tool` + `start_offset` are only set when the
1548 2004 // client is continuing a previous search from the `continue`
1549 2005 // object returned by an exhausted run. Fresh searches leave
1550 2006 // both unset — the agent picks tools from query semantics.
1551 - 'resume_tool' => array(
2007 + 'resume_tool' => array(
1552 2008 'required' => false,
1553 2009 'type' => array( 'string', 'null' ),
1554 2010 'default' => null,
1555 2011 'sanitize_callback' => static function ( $v ) {
1556 - return in_array( $v, desktop_mode_ai_search_resumable_tools(), true )
2012 + return in_array( $v, openstation_ai_search_resumable_tools(), true )
1557 2013 ? $v : null;
1558 - },
2014 + },
1559 2015 ),
1560 - 'start_offset' => array(
2016 + 'start_offset' => array(
1561 2017 'required' => false,
1562 2018 'type' => 'integer',
1563 2019 'default' => 0,
1564 2020 'sanitize_callback' => 'absint',
@@ -1567,9 +2023,9 @@
1567 2023 // opted in as AI tools. Each entry: { slug, label, description?, hint? }.
1568 2024 // The slug is namespaced server-side as `command_<slug>`
1569 2025 // and any tool_call the model emits with that name short-
1570 2026 // circuits back to the client for local dispatch.
1571 - 'command_tools' => array(
2027 + 'command_tools' => array(
1572 2028 'required' => false,
1573 2029 'type' => 'array',
1574 2030 'default' => array(),
1575 2031 'items' => array(
@@ -1582,12 +2038,12 @@
1582 2038 ),
1583 2039 ),
1584 2040 ),
1585 2041 // Free-form system-prompt override.
1586 - // mode: 'append' → concatenated onto the built-in prompt (safe for everyone)
1587 - // mode: 'replace' → replaces the built-in prompt entirely, gated on
1588 - // `desktop_mode_ai_system_prompt_replace_capability`
1589 - // (default `manage_options`).
2042 + // mode: 'append' → concatenated onto the built-in prompt (safe for everyone)
2043 + // mode: 'replace' → replaces the built-in prompt entirely, gated on
2044 + // `openstation_ai_system_prompt_replace_capability`
2045 + // (default `manage_options`).
1590 2046 'system_prompt_text' => array(
1591 2047 'required' => false,
1592 2048 'type' => 'string',
1593 2049 'default' => '',
@@ -1604,9 +2060,9 @@
1604 2060 // When present, the endpoint SKIPS the agent loop entirely
1605 2061 // and runs a single-turn "summarise this outcome" call
1606 2062 // through the provider instead. The client sends this on the
1607 2063 // second leg of `ask( q, { tools: 'aiCallable', followUp: true } )`.
1608 - 'follow_up' => array(
2064 + 'follow_up' => array(
1609 2065 'required' => false,
1610 2066 'type' => array( 'object', 'null' ),
1611 2067 'default' => null,
1612 2068 ),
@@ -1613,37 +2069,44 @@
1613 2069 ),
1614 2070 )
1615 2071 );
1616 2072 }
1617 -add_action( 'rest_api_init', 'desktop_mode_register_ai_search_rest_route' );
2073 +add_action( 'rest_api_init', 'openstation_register_ai_search_rest_route' );
1618 2074
1619 2075 /**
1620 2076 * Permission callback.
1621 2077 *
1622 - * @since 0.5.0
1623 - *
1624 2078 * @return bool|WP_Error
1625 2079 */
1626 -function desktop_mode_rest_ai_search_permission() {
2080 +function openstation_rest_ai_search_permission() {
1627 2081 if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
1628 2082 return new WP_Error(
1629 - 'desktop_mode_ai_forbidden',
1630 - 'You must be logged in to use the AI assistant.',
2083 + 'openstation_ai_forbidden',
2084 + __( 'You must be logged in to use the AI assistant.', 'desktop-mode' ),
1631 2085 array( 'status' => 403 )
1632 2086 );
1633 2087 }
1634 - if ( ! desktop_mode_ai_is_available() ) {
2088 + if ( ! openstation_ai_is_available() ) {
1635 2089 return new WP_Error(
1636 - 'desktop_mode_ai_unavailable',
1637 - 'The AI assistant is unavailable on this site.',
2090 + 'openstation_ai_unavailable',
2091 + __( 'The AI assistant is unavailable on this site.', 'desktop-mode' ),
1638 2092 array( 'status' => 503 )
1639 2093 );
1640 2094 }
1641 - if ( ! desktop_mode_ai_is_enabled( get_current_user_id() ) ) {
2095 + if ( ! openstation_ai_is_enabled( get_current_user_id() ) ) {
2096 + // The message names the tab for consumers that only get text (a REST
2097 + // client, `wp.os.ai.ask()`). `settings_tab` names it again as data,
2098 + // so the overlay can offer a one-click link without parsing prose.
1642 2099 return new WP_Error(
1643 - 'desktop_mode_ai_disabled',
1644 - 'The AI assistant is turned off. Enable it in OS Settings → Features.',
1645 - array( 'status' => 403 )
2100 + 'openstation_ai_disabled',
2101 + __(
2102 + 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.',
2103 + 'desktop-mode'
2104 + ),
2105 + array(
2106 + 'status' => 403,
2107 + 'settings_tab' => 'features',
2108 + )
1646 2109 );
1647 2110 }
1648 2111 return true;
1649 2112 }
@@ -1650,14 +2113,18 @@
1650 2113
1651 2114 /**
1652 2115 * POST /desktop-mode/v1/ai/search
1653 2116 *
1654 - * @since 0.5.0
1655 - *
1656 2117 * @param WP_REST_Request $request
1657 2118 * @return WP_REST_Response|WP_Error
1658 2119 */
1659 -function desktop_mode_rest_ai_search( WP_REST_Request $request ) {
2120 +function openstation_rest_ai_search( WP_REST_Request $request ) {
2121 + // The agent loop runs up to OPENSTATION_AI_SEARCH_MAX_ITERATIONS model
2122 + // round-trips, each with a tool call, which overruns a default 30s
2123 + // max_execution_time. The streaming endpoint used to raise this; it is
2124 + // the only path now, so it carries the limit.
2125 + @set_time_limit( 120 ); // phpcs:ignore
2126 +
1660 2127 $user_id = get_current_user_id();
1661 2128 $query = $request->get_param( 'query' );
1662 2129 $resume_tool = $request->get_param( 'resume_tool' );
1663 2130 $start_offset = $request->get_param( 'start_offset' );
@@ -1668,9 +2135,9 @@
1668 2135 }
1669 2136
1670 2137 $extra = array(
1671 2138 'user_id' => $user_id,
1672 - 'request_id' => function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ),
2139 + 'request_id' => function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ),
1673 2140 'command_tools' => $command_tools,
1674 2141 'system_prompt_text' => (string) $request->get_param( 'system_prompt_text' ),
1675 2142 'system_prompt_mode' => (string) $request->get_param( 'system_prompt_mode' ),
1676 2143 );
@@ -1679,15 +2146,13 @@
1679 2146 * Last-mile filter on the whole `/ai/search` request bundle.
1680 2147 * Plugins get one hook to rewrite query, swap tools, or inject
1681 2148 * metadata before the agent loop starts.
1682 2149 *
1683 - * @since 0.5.1
1684 - *
1685 2150 * @param array $extra Extended context (mutable).
1686 2151 * @param array $core Core request params { query, resume_tool, start_offset }.
1687 2152 */
1688 2153 $extra = (array) apply_filters(
1689 - 'desktop_mode_ai_request',
2154 + 'openstation_ai_request',
1690 2155 $extra,
1691 2156 array(
1692 2157 'query' => $query,
1693 2158 'resume_tool' => $resume_tool,
@@ -1701,17 +2166,17 @@
1701 2166 $tool = $follow_up['tool'];
1702 2167 $outcome = isset( $follow_up['result'] )
1703 2168 ? ( is_array( $follow_up['result'] ) ? $follow_up['result'] : array( 'value' => $follow_up['result'] ) )
1704 2169 : array();
1705 - $result = desktop_mode_ai_run_followup( $query, $tool, $outcome, $extra );
2170 + $result = openstation_ai_run_followup( $query, $tool, $outcome, $extra );
1706 2171 } else {
1707 - $result = desktop_mode_ai_run_search( $query, $resume_tool, $start_offset, null, $extra );
2172 + $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, $extra );
1708 2173 }
1709 2174
1710 2175 if ( is_wp_error( $result ) ) {
1711 2176 $request_id = isset( $extra['request_id'] ) ? (string) $extra['request_id'] : '';
1712 2177 do_action(
1713 - 'desktop_mode_ai_search_error',
2178 + 'openstation_ai_search_error',
1714 2179 array(
1715 2180 'code' => $result->get_error_code(),
1716 2181 'message' => $result->get_error_message(),
1717 2182 'data' => $result->get_error_data(),
@@ -1733,16 +2198,14 @@
1733 2198
1734 2199 /**
1735 2200 * Search the WordPress.org plugin directory.
1736 2201 *
1737 - * @since 0.5.0
1738 - *
1739 2202 * @param string $query Search terms.
1740 2203 * @return array Tool result payload ready for the model.
1741 2204 */
1742 -function desktop_mode_ai_fetch_wporg_plugins( $query ) {
2205 +function openstation_ai_fetch_wporg_plugins( $query ) {
1743 2206 $query = trim( (string) $query );
1744 - if ( $query === '' ) {
2207 + if ( '' === $query ) {
1745 2208 return array(
1746 2209 'tool' => 'search_wporg_plugins',
1747 2210 'query' => '',
1748 2211 'results' => array(),
@@ -1752,9 +2215,9 @@
1752 2215 }
1753 2216
1754 2217 // Transient cache to protect the w.org API from repeated queries
1755 2218 // within the same conversation.
1756 - $cache_key = 'desktop_mode_ai_plugins_' . md5( strtolower( $query ) );
2219 + $cache_key = 'openstation_ai_plugins_' . md5( strtolower( $query ) );
1757 2220 $cached = get_transient( $cache_key );
1758 2221 if ( is_array( $cached ) ) {
1759 2222 return $cached;
1760 2223 }
@@ -1811,9 +2274,9 @@
1811 2274 // Normalise — plugins_api sometimes returns arrays, sometimes objects.
1812 2275 $p = (array) $p;
1813 2276
1814 2277 $slug = isset( $p['slug'] ) ? (string) $p['slug'] : '';
1815 - if ( $slug === '' ) {
2278 + if ( '' === $slug ) {
1816 2279 continue;
1817 2280 }
1818 2281
1819 2282 $icon = '';
@@ -1840,8 +2303,9 @@
1840 2303 'short_description' => wp_strip_all_tags( $p['short_description'] ?? '' ),
1841 2304 'version' => (string) ( $p['version'] ?? '' ),
1842 2305 'author' => wp_strip_all_tags( $p['author'] ?? '' ),
1843 2306 'rating' => (int) ( $p['rating'] ?? 0 ), // 0-100
2307 + 'stars' => round( ( (int) ( $p['rating'] ?? 0 ) ) / 20, 1 ), // 0-5, as wordpress.org shows it
1844 2308 'num_ratings' => (int) ( $p['num_ratings'] ?? 0 ),
1845 2309 'active_installs' => (int) ( $p['active_installs'] ?? 0 ),
1846 2310 'last_updated' => (string) ( $p['last_updated'] ?? '' ),
1847 2311 'requires' => (string) ( $p['requires'] ?? '' ),
@@ -1879,20 +2343,18 @@
1879 2343 * falls back to the PHP ini `error_log` directive. If neither points at
1880 2344 * a readable file the tool reports log_available=false rather than
1881 2345 * throwing.
1882 2346 *
1883 - * @since 0.5.0
1884 - *
1885 2347 * @param int $lines Number of lines to return (clamped 1-500 by caller).
1886 2348 * @return array
1887 2349 */
1888 -function desktop_mode_ai_fetch_error_log( $lines = 50 ) {
2350 +function openstation_ai_fetch_error_log( $lines = 50 ) {
1889 2351 $candidates = array();
1890 2352 if ( defined( 'WP_CONTENT_DIR' ) ) {
1891 2353 $candidates[] = WP_CONTENT_DIR . '/debug.log';
1892 2354 }
1893 2355 $ini_log = (string) ini_get( 'error_log' );
1894 - if ( $ini_log !== '' && 'syslog' !== $ini_log ) {
2356 + if ( '' !== $ini_log && 'syslog' !== $ini_log ) {
1895 2357 $candidates[] = $ini_log;
1896 2358 }
1897 2359
1898 2360 /**
@@ -1898,13 +2360,11 @@
1898 2360 /**
1899 2361 * Filter the list of log-file paths to probe in order. Plugins that
1900 2362 * redirect errors somewhere non-standard can add their path here.
1901 2363 *
1902 - * @since 0.5.0
1903 - *
1904 2364 * @param string[] $candidates File paths, in probe order.
1905 2365 */
1906 - $candidates = (array) apply_filters( 'desktop_mode_ai_error_log_candidates', $candidates );
2366 + $candidates = (array) apply_filters( 'openstation_ai_error_log_candidates', $candidates );
1907 2367
1908 2368 $log_path = '';
1909 2369 foreach ( $candidates as $path ) {
1910 2370 if ( is_string( $path ) && is_file( $path ) && is_readable( $path ) ) {
@@ -1912,9 +2372,9 @@
1912 2372 break;
1913 2373 }
1914 2374 }
1915 2375
1916 - if ( $log_path === '' ) {
2376 + if ( '' === $log_path ) {
1917 2377 return array(
1918 2378 'tool' => 'get_php_error_log',
1919 2379 'log_available' => false,
1920 2380 'message' => 'No readable error log found. Enable WP_DEBUG_LOG in wp-config.php or set php_value error_log.',
@@ -1923,17 +2383,17 @@
1923 2383 'count' => 0,
1924 2384 );
1925 2385 }
1926 2386
1927 - $tail = desktop_mode_ai_tail_file( $log_path, $lines );
2387 + $tail = openstation_ai_tail_file( $log_path, $lines );
1928 2388
1929 2389 $entries = array();
1930 2390 foreach ( $tail as $line ) {
1931 2391 $line = trim( $line );
1932 - if ( $line === '' ) {
2392 + if ( '' === $line ) {
1933 2393 continue;
1934 2394 }
1935 - $entries[] = desktop_mode_ai_parse_log_line( $line );
2395 + $entries[] = openstation_ai_parse_log_line( $line );
1936 2396 }
1937 2397
1938 2398 return array(
1939 2399 'tool' => 'get_php_error_log',
@@ -1950,14 +2410,12 @@
1950 2410 * PHP's default format is `[<date>] <prefix>: <message>` where the
1951 2411 * prefix is usually "PHP Fatal error", "PHP Warning", etc. Falls back
1952 2412 * to a raw line when the format doesn't match.
1953 2413 *
1954 - * @since 0.5.0
1955 - *
1956 2414 * @param string $line
1957 2415 * @return array
1958 2416 */
1959 -function desktop_mode_ai_parse_log_line( $line ) {
2417 +function openstation_ai_parse_log_line( $line ) {
1960 2418 // Cap individual messages so a runaway stack trace doesn't balloon
1961 2419 // the payload sent to the provider.
1962 2420 $line = mb_substr( $line, 0, 600 );
1963 2421
@@ -1987,15 +2445,13 @@
1987 2445 * slices off the trailing N+1 entries. Realistic error logs sit
1988 2446 * in the kilobyte range when admins look at them; if a site routinely
1989 2447 * lets logs grow into tens of MB, that's the symptom, not this read.
1990 2448 *
1991 - * @since 0.5.0
1992 - *
1993 2449 * @param string $path Absolute path to the file.
1994 2450 * @param int $lines
1995 2451 * @return string[] Lines in original order (oldest first).
1996 2452 */
1997 -function desktop_mode_ai_tail_file( $path, $lines ) {
2453 +function openstation_ai_tail_file( $path, $lines ) {
1998 2454 if ( ! function_exists( 'WP_Filesystem' ) ) {
1999 2455 require_once ABSPATH . 'wp-admin/includes/file.php';
2000 2456 }
2001 2457 WP_Filesystem();
@@ -2015,118 +2471,4 @@
2015 2471 }
2016 2472
2017 2473 return array_slice( $all, -1 * ( $lines + 1 ) );
2018 2474 }
2019 -
2020 -// ---------------------------------------------------------------------------
2021 -// Streaming endpoint (Server-Sent Events)
2022 -//
2023 -// EventSource can't send POST or custom headers, so we ride admin-ajax.php
2024 -// which handles cookie-based auth natively. The nonce goes in the URL.
2025 -// Output buffering is forcibly disabled and every emit is flushed so the
2026 -// browser receives progress ticks in real time.
2027 -// ---------------------------------------------------------------------------
2028 -
2029 -/**
2030 - * admin-ajax handler for the streaming search endpoint.
2031 - *
2032 - * URL: /wp-admin/admin-ajax.php?action=desktop_mode_ai_search_stream
2033 - * &nonce=<rest_nonce>
2034 - * &query=<user question>
2035 - * &resume_tool=<search_posts|…> (optional)
2036 - * &start_offset=<int> (optional)
2037 - *
2038 - * Emits SSE events:
2039 - * data: { "event": "progress", "phase": "tool_call", "message": "…" }
2040 - * data: { "event": "done", "result": { … } }
2041 - * data: { "event": "error", "message": "…" }
2042 - *
2043 - * @since 0.5.0
2044 - */
2045 -function desktop_mode_ai_ajax_search_stream() {
2046 - $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
2047 - if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
2048 - status_header( 403 );
2049 - exit;
2050 - }
2051 - if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
2052 - status_header( 403 );
2053 - exit;
2054 - }
2055 - $user_id = get_current_user_id();
2056 - if ( ! desktop_mode_ai_is_available() ) {
2057 - status_header( 503 );
2058 - exit;
2059 - }
2060 - if ( ! desktop_mode_ai_is_enabled( $user_id ) ) {
2061 - status_header( 403 );
2062 - exit;
2063 - }
2064 -
2065 - $query = isset( $_GET['query'] ) ? sanitize_text_field( wp_unslash( $_GET['query'] ) ) : ''; // phpcs:ignore WordPress.Security
2066 - if ( trim( $query ) === '' ) {
2067 - status_header( 400 );
2068 - exit;
2069 - }
2070 -
2071 - $resume_tool = isset( $_GET['resume_tool'] ) ? sanitize_key( wp_unslash( $_GET['resume_tool'] ) ) : null; // phpcs:ignore WordPress.Security
2072 - $start_offset = isset( $_GET['start_offset'] ) ? absint( $_GET['start_offset'] ) : 0; // phpcs:ignore WordPress.Security
2073 - if ( $resume_tool !== null && ! in_array( $resume_tool, desktop_mode_ai_search_resumable_tools(), true ) ) {
2074 - $resume_tool = null;
2075 - }
2076 -
2077 - // SSE headers — tell nginx to stop buffering, tell the browser this is
2078 - // a persistent event stream.
2079 - header( 'Content-Type: text/event-stream; charset=utf-8' );
2080 - header( 'Cache-Control: no-cache, no-store, must-revalidate' );
2081 - header( 'X-Accel-Buffering: no' );
2082 - header( 'Connection: keep-alive' );
2083 -
2084 - // Let other requests from this user proceed (release session lock).
2085 - if ( session_status() === PHP_SESSION_ACTIVE ) {
2086 - session_write_close();
2087 - }
2088 -
2089 - // Kill any output buffers PHP set up, otherwise nothing flushes until
2090 - // the request ends — which defeats the whole point of streaming.
2091 - while ( ob_get_level() > 0 ) {
2092 - @ob_end_flush(); // phpcs:ignore
2093 - }
2094 - @ini_set( 'output_buffering', 'off' ); // phpcs:ignore
2095 - @ini_set( 'zlib.output_compression', 'off' ); // phpcs:ignore
2096 - @set_time_limit( 120 ); // phpcs:ignore
2097 -
2098 - $emit = static function ( array $payload ) {
2099 - echo 'data: ' . wp_json_encode( $payload ) . "\n\n";
2100 - @ob_flush(); // phpcs:ignore
2101 - flush();
2102 - };
2103 -
2104 - // Initial tick so the EventSource opens immediately and the JS can
2105 - // start showing "Thinking…" without waiting for the first model call.
2106 - $emit( array( 'event' => 'open' ) );
2107 -
2108 - $result = desktop_mode_ai_run_search(
2109 - $query,
2110 - $resume_tool,
2111 - $start_offset,
2112 - function ( $progress ) use ( $emit ) {
2113 - $emit( array_merge( array( 'event' => 'progress' ), $progress ) );
2114 - }
2115 - );
2116 -
2117 - if ( is_wp_error( $result ) ) {
2118 - $emit( array(
2119 - 'event' => 'error',
2120 - 'message' => $result->get_error_message(),
2121 - 'code' => $result->get_error_code(),
2122 - ) );
2123 - } else {
2124 - $emit( array(
2125 - 'event' => 'done',
2126 - 'result' => $result,
2127 - ) );
2128 - }
2129 -
2130 - exit;
2131 -}
2132 -add_action( 'wp_ajax_desktop_mode_ai_search_stream', 'desktop_mode_ai_ajax_search_stream' );