PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
← All changes | includes/ai-copilot/search.php +672 -405 0.9.81.1.10 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,46 +50,196 @@
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 58 * @return array[]
59 59 */
60 -function desktop_mode_ai_get_admin_page_catalog() {
60 +function openstation_ai_get_admin_page_catalog() {
61 61 $catalog = array(
62 - array( 'title' => 'Dashboard', 'url' => admin_url( 'index.php' ), 'icon' => 'dashicons-dashboard', 'description' => 'The main admin dashboard — activity, drafts, site overview.' ),
63 - array( 'title' => 'All Posts', 'url' => admin_url( 'edit.php' ), 'icon' => 'dashicons-admin-post', 'description' => 'List, edit, bulk-manage blog posts.' ),
64 - array( 'title' => 'Add New Post', 'url' => admin_url( 'post-new.php' ), 'icon' => 'dashicons-plus', 'description' => 'Create a new blog post.' ),
65 - array( 'title' => 'Categories', 'url' => admin_url( 'edit-tags.php?taxonomy=category' ), 'icon' => 'dashicons-category', 'description' => 'Manage post categories — add, rename, merge.' ),
66 - array( 'title' => 'Tags', 'url' => admin_url( 'edit-tags.php?taxonomy=post_tag' ), 'icon' => 'dashicons-tag', 'description' => 'Manage post tags.' ),
67 - 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.).' ),
68 - array( 'title' => 'Add New Page', 'url' => admin_url( 'post-new.php?post_type=page' ), 'icon' => 'dashicons-plus', 'description' => 'Create a new page.' ),
69 - array( 'title' => 'Media Library', 'url' => admin_url( 'upload.php' ), 'icon' => 'dashicons-admin-media', 'description' => 'Browse, upload, and manage images, files, videos.' ),
70 - array( 'title' => 'Comments', 'url' => admin_url( 'edit-comments.php' ), 'icon' => 'dashicons-admin-comments', 'description' => 'Moderate and reply to comments on posts and pages.' ),
71 - array( 'title' => 'Themes', 'url' => admin_url( 'themes.php' ), 'icon' => 'dashicons-admin-appearance', 'description' => 'Change, install, or customize the active theme.' ),
72 - array( 'title' => 'Customize', 'url' => admin_url( 'customize.php' ), 'icon' => 'dashicons-admin-customizer', 'description' => 'Live-preview theme customisation — colors, fonts, layout.' ),
73 - array( 'title' => 'Widgets', 'url' => admin_url( 'widgets.php' ), 'icon' => 'dashicons-screenoptions', 'description' => 'Manage sidebar and footer widgets.' ),
74 - array( 'title' => 'Menus', 'url' => admin_url( 'nav-menus.php' ), 'icon' => 'dashicons-menu', 'description' => 'Create and edit navigation menus.' ),
75 - array( 'title' => 'Plugins', 'url' => admin_url( 'plugins.php' ), 'icon' => 'dashicons-admin-plugins', 'description' => 'Activate, deactivate, update or delete plugins.' ),
76 - array( 'title' => 'Add New Plugin', 'url' => admin_url( 'plugin-install.php' ), 'icon' => 'dashicons-plus', 'description' => 'Search and install new plugins from the directory.' ),
77 - array( 'title' => 'Users', 'url' => admin_url( 'users.php' ), 'icon' => 'dashicons-admin-users', 'description' => 'Manage user accounts and roles.' ),
78 - array( 'title' => 'Add New User', 'url' => admin_url( 'user-new.php' ), 'icon' => 'dashicons-plus', 'description' => 'Create a new user account.' ),
79 - array( 'title' => 'Your Profile', 'url' => admin_url( 'profile.php' ), 'icon' => 'dashicons-id', 'description' => 'Edit your own profile, password, admin colour scheme.' ),
80 - array( 'title' => 'General Settings', 'url' => admin_url( 'options-general.php' ), 'icon' => 'dashicons-admin-settings', 'description' => 'Site title, tagline, URL, timezone, language.' ),
81 - array( 'title' => 'Writing Settings', 'url' => admin_url( 'options-writing.php' ), 'icon' => 'dashicons-edit', 'description' => 'Default post category, post format, remote publishing.' ),
82 - array( 'title' => 'Reading Settings', 'url' => admin_url( 'options-reading.php' ), 'icon' => 'dashicons-book', 'description' => 'Homepage, blog posts per page, search-engine visibility.' ),
83 - array( 'title' => 'Discussion Settings','url' => admin_url( 'options-discussion.php' ), 'icon' => 'dashicons-format-chat', 'description' => 'Comment moderation, avatars, email notifications.' ),
84 - array( 'title' => 'Media Settings', 'url' => admin_url( 'options-media.php' ), 'icon' => 'dashicons-format-image', 'description' => 'Image size settings for thumbnail / medium / large.' ),
85 - array( 'title' => 'Permalinks', 'url' => admin_url( 'options-permalink.php' ), 'icon' => 'dashicons-admin-links', 'description' => 'URL structure for posts, pages, categories, tags.' ),
86 - array( 'title' => 'Privacy', 'url' => admin_url( 'options-privacy.php' ), 'icon' => 'dashicons-privacy', 'description' => 'Privacy policy page selection and preview.' ),
87 - array( 'title' => 'Tools', 'url' => admin_url( 'tools.php' ), 'icon' => 'dashicons-admin-tools', 'description' => 'Built-in site tools.' ),
88 - array( 'title' => 'Import', 'url' => admin_url( 'import.php' ), 'icon' => 'dashicons-download', 'description' => 'Import content from other platforms (WP, Tumblr, RSS, etc.).' ),
89 - array( 'title' => 'Export', 'url' => admin_url( 'export.php' ), 'icon' => 'dashicons-upload', 'description' => 'Export all site content as XML.' ),
90 - array( 'title' => 'Site Health', 'url' => admin_url( 'site-health.php' ), 'icon' => 'dashicons-heart', 'description' => 'Performance and security recommendations for the site.' ),
91 - 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 + ),
92 242 );
93 243
94 244 /**
95 245 * Filters the wp-admin page catalog surfaced by the AI assistant.
@@ -95,9 +245,9 @@
95 245 * Filters the wp-admin page catalog surfaced by the AI assistant.
96 246 *
97 247 * @param array[] $catalog Array of entries, each with title/url/icon/description.
98 248 */
99 - return (array) apply_filters( 'desktop_mode_ai_admin_page_catalog', $catalog );
249 + return (array) apply_filters( 'openstation_ai_admin_page_catalog', $catalog );
100 250 }
101 251
102 252 // ---------------------------------------------------------------------------
103 253 // Final-answer JSON Schema
@@ -107,9 +257,9 @@
107 257 * JSON Schema for the agent's final structured answer.
108 258 *
109 259 * @return array
110 260 */
111 -function desktop_mode_ai_search_answer_schema() {
261 +function openstation_ai_search_answer_schema() {
112 262 return array(
113 263 'type' => 'object',
114 264 'additionalProperties' => false,
115 265 'required' => array( 'answer_type', 'message', 'entity_id', 'entity_type', 'admin_links' ),
@@ -131,9 +281,12 @@
131 281 'description' => 'The WordPress ID of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
132 282 ),
133 283 'entity_type' => array(
134 284 'anyOf' => array(
135 - array( 'type' => 'string', 'enum' => array( 'post', 'page', 'comment' ) ),
285 + array(
286 + 'type' => 'string',
287 + 'enum' => array( 'post', 'page', 'comment' ),
288 + ),
136 289 array( 'type' => 'null' ),
137 290 ),
138 291 'description' => 'Type of the matching entity. Required when answer_type is "entity"; set to null otherwise.',
139 292 ),
@@ -177,30 +330,30 @@
177 330 * @param string $tool_name Tool function name.
178 331 * @param array $args Decoded arguments from the model's tool call.
179 332 * @return array Tool result payload.
180 333 */
181 -function desktop_mode_ai_search_dispatch_tool( $tool_name, array $args ) {
334 +function openstation_ai_search_dispatch_tool( $tool_name, array $args ) {
182 335 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
183 336 $query = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : '';
184 337
185 338 switch ( $tool_name ) {
186 339 case 'search_posts':
187 - return desktop_mode_ai_search_fetch_posts( 'post', $query, $offset );
340 + return openstation_ai_search_fetch_posts( 'post', $query, $offset );
188 341 case 'search_pages':
189 - return desktop_mode_ai_search_fetch_posts( 'page', $query, $offset );
342 + return openstation_ai_search_fetch_posts( 'page', $query, $offset );
190 343 case 'search_comments':
191 - return desktop_mode_ai_search_fetch_comments( $query, $offset );
344 + return openstation_ai_search_fetch_comments( $query, $offset );
192 345 case 'search_comments_by_post':
193 346 $post_id = max( 0, (int) ( $args['post_id'] ?? 0 ) );
194 - 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 );
195 348 case 'list_admin_pages':
196 349 return array(
197 350 'tool' => 'list_admin_pages',
198 - 'pages' => desktop_mode_ai_get_admin_page_catalog(),
351 + 'pages' => openstation_ai_get_admin_page_catalog(),
199 352 );
200 353 case 'search_wporg_plugins':
201 354 $q = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : '';
202 - return desktop_mode_ai_fetch_wporg_plugins( $q );
355 + return openstation_ai_fetch_wporg_plugins( $q );
203 356 case 'get_php_error_log':
204 357 if ( ! current_user_can( 'manage_options' ) ) {
205 358 return array(
206 359 'tool' => 'get_php_error_log',
@@ -209,9 +362,9 @@
209 362 'entries' => array(),
210 363 );
211 364 }
212 365 $lines = isset( $args['lines'] ) ? max( 1, min( 500, (int) $args['lines'] ) ) : 50;
213 - return desktop_mode_ai_fetch_error_log( $lines );
366 + return openstation_ai_fetch_error_log( $lines );
214 367 }
215 368
216 369 return array(
217 370 'tool' => $tool_name,
@@ -228,22 +381,29 @@
228 381 * Keyword-searches published posts or pages with WordPress's native search
229 382 * (`WP_Query` `s=`), returning data rich enough for the agent to compare
230 383 * AND for the UI to render links.
231 384 *
232 - * 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.
233 386 *
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.
392 + *
234 393 * @param string $post_type 'post' | 'page'.
235 394 * @param string $query Keyword search terms (may be empty to list newest).
236 395 * @param int $offset
237 396 * @return array
238 397 */
239 -function desktop_mode_ai_search_fetch_posts( $post_type, $query, $offset ) {
398 +function openstation_ai_search_fetch_posts( $post_type, $query, $offset ) {
240 399 $wp_query = new WP_Query(
241 400 array(
242 401 'post_type' => $post_type,
243 402 'post_status' => 'publish',
403 + 'has_password' => false,
244 404 's' => (string) $query,
245 - 'posts_per_page' => DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
405 + 'posts_per_page' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
246 406 'offset' => $offset,
247 407 'no_found_rows' => false,
248 408 'update_post_term_cache' => false,
249 409 'update_post_meta_cache' => false,
@@ -257,9 +417,9 @@
257 417 'id' => $post->ID,
258 418 'type' => $post->post_type,
259 419 // Comparison data for the model — real title + content excerpt.
260 420 'title' => wp_strip_all_tags( $post->post_title ),
261 - 'excerpt' => desktop_mode_ai_search_excerpt( $post->post_content ),
421 + 'excerpt' => openstation_ai_search_excerpt( $post->post_content ),
262 422 'date' => $post->post_date ? substr( $post->post_date, 0, 10 ) : '',
263 423 // Links — passed through so the UI can link to the entity
264 424 // once the agent identifies a match.
265 425 'url' => (string) get_permalink( $post ),
@@ -275,10 +435,10 @@
275 435 'offset' => $offset,
276 436 'items' => $items,
277 437 'count' => count( $items ),
278 438 'total' => $total,
279 - 'has_more' => ( $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE ) < $total,
280 - '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,
281 441 );
282 442 }
283 443
284 444 /**
@@ -286,9 +446,9 @@
286 446 *
287 447 * @param string $content Raw post/comment content.
288 448 * @return string
289 449 */
290 -function desktop_mode_ai_search_excerpt( $content ) {
450 +function openstation_ai_search_excerpt( $content ) {
291 451 $text = wp_strip_all_tags( (string) $content );
292 452 $text = preg_replace( '/\s+/', ' ', trim( $text ) );
293 453 return (string) mb_substr( $text, 0, 300 );
294 454 }
@@ -293,8 +453,82 @@
293 453 return (string) mb_substr( $text, 0, 300 );
294 454 }
295 455
296 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 +/**
297 531 * Keyword-searches approved comments across all posts with WordPress's
298 532 * native comment search (`get_comments` `search=`).
299 533 *
300 534 * No AI analysis is required — every approved comment is searchable.
@@ -302,9 +536,9 @@
302 536 * @param string $query Keyword search terms (may be empty to list newest).
303 537 * @param int $offset
304 538 * @return array
305 539 */
306 -function desktop_mode_ai_search_fetch_comments( $query, $offset ) {
540 +function openstation_ai_search_fetch_comments( $query, $offset ) {
307 541 $base_args = array(
308 542 'status' => 'approve',
309 543 'type' => 'comment',
310 544 'search' => (string) $query,
@@ -309,32 +543,55 @@
309 543 'type' => 'comment',
310 544 'search' => (string) $query,
311 545 );
312 546
313 - $comments = get_comments( array_merge( $base_args, array(
314 - 'number' => DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
315 - 'offset' => $offset,
316 - 'count' => false,
317 - ) ) );
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 + );
318 557
319 558 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
320 559
321 560 // Prime the parent posts in a single query so the per-comment
322 561 // get_post() calls below are cache hits, not N+1 round-trips.
323 - $parent_ids = array_unique( array_map(
324 - static function ( $c ) {
325 - return (int) $c->comment_post_ID;
326 - },
327 - $comments
328 - ) );
562 + $parent_ids = array_unique(
563 + array_map(
564 + static function ( $c ) {
565 + return (int) $c->comment_post_ID;
566 + },
567 + $comments
568 + )
569 + );
329 570 if ( $parent_ids ) {
330 571 _prime_post_caches( $parent_ids, false, false );
331 572 }
332 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 +
333 589 $items = array();
334 590 foreach ( $comments as $comment ) {
591 + // Readable, per the filter above.
335 592 $parent_post = get_post( $comment->comment_post_ID );
336 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
593 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
337 594
338 595 $items[] = array(
339 596 'id' => (int) $comment->comment_ID,
340 597 'type' => 'comment',
@@ -339,14 +596,14 @@
339 596 'id' => (int) $comment->comment_ID,
340 597 'type' => 'comment',
341 598 // Comparison data — real comment text + parent post title.
342 599 'post_title' => $parent_title,
343 - 'excerpt' => desktop_mode_ai_search_excerpt( $comment->comment_content ),
600 + 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
344 601 // Links.
345 602 'url' => (string) get_comment_link( $comment ),
346 603 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
347 604 'post_id' => (int) $comment->comment_post_ID,
348 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
605 + 'post_url' => (string) get_permalink( $parent_post ),
349 606 );
350 607 }
351 608
352 609 return array(
@@ -355,10 +612,10 @@
355 612 'offset' => $offset,
356 613 'items' => $items,
357 614 'count' => count( $items ),
358 615 'total' => $total,
359 - 'has_more' => ( $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE ) < $total,
360 - '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,
361 618 );
362 619 }
363 620
364 621 // ---------------------------------------------------------------------------
@@ -377,9 +634,9 @@
377 634 * @param string $query Keyword search terms (may be empty).
378 635 * @param int $offset
379 636 * @return array Tool result payload.
380 637 */
381 -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 ) {
382 639 $post_id = (int) $post_id;
383 640
384 641 if ( $post_id <= 0 ) {
385 642 return array(
@@ -393,8 +650,25 @@
393 650 'error' => 'post_id must be a positive integer.',
394 651 );
395 652 }
396 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 +
397 671 $base_args = array(
398 672 'post_id' => $post_id,
399 673 'status' => 'approve',
400 674 'type' => 'comment',
@@ -400,18 +674,24 @@
400 674 'type' => 'comment',
401 675 'search' => (string) $query,
402 676 );
403 677
404 - $comments = get_comments( array_merge( $base_args, array(
405 - 'number' => DESKTOP_MODE_AI_SEARCH_BATCH_SIZE,
406 - 'offset' => $offset,
407 - 'count' => false,
408 - ) ) );
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 + );
409 688
410 689 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
411 690
691 + // Readable, per the gate above.
412 692 $parent_post = get_post( $post_id );
413 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
693 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
414 694
415 695 $items = array();
416 696 foreach ( $comments as $comment ) {
417 697 $items[] = array(
@@ -418,9 +698,9 @@
418 698 'id' => (int) $comment->comment_ID,
419 699 'type' => 'comment',
420 700 'post_id' => $post_id,
421 701 'post_title' => $parent_title,
422 - 'excerpt' => desktop_mode_ai_search_excerpt( $comment->comment_content ),
702 + 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
423 703 'url' => (string) get_comment_link( $comment ),
424 704 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
425 705 );
426 706 }
@@ -433,10 +713,10 @@
433 713 'offset' => $offset,
434 714 'items' => $items,
435 715 'count' => count( $items ),
436 716 'total' => $total,
437 - 'has_more' => ( $offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE ) < $total,
438 - '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,
439 719 );
440 720 }
441 721
442 722 // ---------------------------------------------------------------------------
@@ -451,20 +731,45 @@
451 731 * required. Comments opportunistically surface the `spam` / `harmful`
452 732 * verdict when the comment-moderation analysis happens to have run, but
453 733 * its absence never blocks the entity from being returned.
454 734 *
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.
748 + *
455 749 * @param string $entity_type 'post' | 'page' | 'comment'.
456 750 * @param int $entity_id
457 751 * @return array|null
458 752 */
459 -function desktop_mode_ai_search_build_entity( $entity_type, $entity_id ) {
753 +function openstation_ai_search_build_entity( $entity_type, $entity_id ) {
460 754 $entity_id = (int) $entity_id;
461 755
462 756 if ( in_array( $entity_type, array( 'post', 'page' ), true ) ) {
463 757 $post = get_post( $entity_id );
464 - 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 ) ) {
465 765 return null;
466 766 }
767 +
768 + if ( ! openstation_ai_can_read_post( $post ) ) {
769 + return null;
770 + }
771 +
467 772 return array(
468 773 'id' => $entity_id,
469 774 'type' => $post->post_type,
470 775 'title' => wp_strip_all_tags( $post->post_title ),
@@ -471,31 +776,55 @@
471 776 'status' => $post->post_status,
472 777 'date' => $post->post_date ? substr( $post->post_date, 0, 10 ) : '',
473 778 'url' => (string) get_permalink( $post ),
474 779 'edit_url' => (string) get_edit_post_link( $entity_id, 'raw' ),
475 - 'excerpt' => desktop_mode_ai_search_excerpt( $post->post_content ),
780 + 'excerpt' => openstation_ai_search_excerpt( $post->post_content ),
476 781 );
477 782 }
478 783
479 784 if ( 'comment' === $entity_type ) {
480 785 $comment = get_comment( $entity_id );
481 - 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 ) ) {
482 792 return null;
483 793 }
484 - $meta = desktop_mode_ai_get_meta( 'comment', $entity_id );
485 - $parent_post = get_post( $comment->comment_post_ID );
486 - 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(
487 808 'id' => $entity_id,
488 809 'type' => 'comment',
489 - 'excerpt' => desktop_mode_ai_search_excerpt( $comment->comment_content ),
810 + 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
490 811 'post_id' => (int) $comment->comment_post_ID,
491 - 'post_title' => $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '',
492 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
812 + 'post_title' => wp_strip_all_tags( $parent_post->post_title ),
813 + 'post_url' => (string) get_permalink( $parent_post ),
493 814 'url' => (string) get_comment_link( $comment ),
494 - 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . $entity_id ),
495 - 'harmful' => $meta ? (bool) ( $meta['harmful'] ?? false ) : false,
496 - '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 + : '',
497 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;
498 827 }
499 828
500 829 return null;
501 830 }
@@ -504,34 +833,37 @@
504 833 // Agentic search loop
505 834 // ---------------------------------------------------------------------------
506 835
507 836 /**
508 - * Returns a friendly progress message for a tool name — surfaced to the
509 - * client via SSE so the user sees "Looking through your posts…" rather
510 - * than the raw tool call.
837 + * Returns the label for the "keep looking" button on an exhausted search.
511 838 *
512 - * @param string $tool_name
839 + * One full sentence per resumable tool: a noun interpolated into a shared
840 + * template cannot be translated.
841 + *
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.
513 844 * @return string
514 845 */
515 -function desktop_mode_ai_progress_message( $tool_name ) {
516 - switch ( $tool_name ) {
517 - case 'search_posts': return 'Looking through your posts…';
518 - case 'search_pages': return 'Checking your pages…';
519 - case 'search_comments': return 'Reading through comments…';
520 - case 'search_comments_by_post': return 'Scanning comments on that post…';
521 - case 'list_admin_pages': return 'Finding the right admin page…';
522 - case 'search_wporg_plugins': return 'Searching the WordPress.org plugin directory…';
523 - 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 );
524 857 }
525 - return 'Thinking…';
526 858 }
527 859
528 860 /**
529 861 * Returns the tools a client may resume an exhausted search from.
530 862 *
531 - * Single source of truth for every `resume_tool` allowlist — the REST
532 - * arg sanitizer, the SSE handler, and the `$initial_tool` validation
533 - * 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
534 866 * deliberately absent: the `continue` payload carries no `post_id`, so
535 867 * it cannot truly resume — exhausted runs map it to `search_comments`
536 868 * when building the `continue` object.
537 869 *
@@ -536,9 +868,9 @@
536 868 * when building the `continue` object.
537 869 *
538 870 * @return string[] Tool names.
539 871 */
540 -function desktop_mode_ai_search_resumable_tools() {
872 +function openstation_ai_search_resumable_tools() {
541 873 return array( 'search_posts', 'search_pages', 'search_comments' );
542 874 }
543 875
544 876 /**
@@ -568,9 +900,9 @@
568 900 *
569 901 * @param mixed $schema A tool parameters schema (array), or empty/non-array.
570 902 * @return array A provider-safe object schema for a tool `parameters` block.
571 903 */
572 -function desktop_mode_ai_normalize_tool_schema( $schema ) {
904 +function openstation_ai_normalize_tool_schema( $schema ) {
573 905 if ( ! is_array( $schema ) || empty( $schema ) ) {
574 906 return array(
575 907 'type' => 'object',
576 908 'properties' => (object) array(),
@@ -579,9 +911,9 @@
579 911
580 912 // Recursively drop the WordPress-only arg-schema keys
581 913 // (`sanitize_callback` / `validate_callback` / `arg_options`) —
582 914 // see the helper's docblock for why this must run at every depth.
583 - $schema = desktop_mode_ai_strip_wp_schema_keys( $schema );
915 + $schema = openstation_ai_strip_wp_schema_keys( $schema );
584 916
585 917 // Top-level tool parameters must be the literal "object", never a union.
586 918 $schema['type'] = 'object';
587 919
@@ -616,14 +948,12 @@
616 948 * values, `items` (single schema or tuple list), array-shaped
617 949 * `additionalProperties`, and nested `oneOf` / `allOf` / `anyOf` branches
618 950 * (which are kept — only the TOP level of the tool schema strips combinators).
619 951 *
620 - * @since 0.9.8
621 - *
622 952 * @param array $schema A tool parameters (sub)schema.
623 953 * @return array The schema without WP-only keys, at any depth.
624 954 */
625 -function desktop_mode_ai_strip_wp_schema_keys( array $schema ) {
955 +function openstation_ai_strip_wp_schema_keys( array $schema ) {
626 956 unset( $schema['sanitize_callback'], $schema['validate_callback'], $schema['arg_options'] );
627 957
628 958 foreach ( array( 'properties', 'patternProperties' ) as $map_key ) {
629 959 if ( isset( $schema[ $map_key ] ) && is_array( $schema[ $map_key ] ) ) {
@@ -628,9 +958,9 @@
628 958 foreach ( array( 'properties', 'patternProperties' ) as $map_key ) {
629 959 if ( isset( $schema[ $map_key ] ) && is_array( $schema[ $map_key ] ) ) {
630 960 foreach ( $schema[ $map_key ] as $name => $sub ) {
631 961 if ( is_array( $sub ) ) {
632 - $schema[ $map_key ][ $name ] = desktop_mode_ai_strip_wp_schema_keys( $sub );
962 + $schema[ $map_key ][ $name ] = openstation_ai_strip_wp_schema_keys( $sub );
633 963 }
634 964 }
635 965 }
636 966 }
@@ -641,19 +971,19 @@
641 971 if ( $is_list && array() !== $items ) {
642 972 // Tuple form — a list of schemas.
643 973 foreach ( $items as $i => $sub ) {
644 974 if ( is_array( $sub ) ) {
645 - $items[ $i ] = desktop_mode_ai_strip_wp_schema_keys( $sub );
975 + $items[ $i ] = openstation_ai_strip_wp_schema_keys( $sub );
646 976 }
647 977 }
648 978 $schema['items'] = $items;
649 979 } else {
650 - $schema['items'] = desktop_mode_ai_strip_wp_schema_keys( $items );
980 + $schema['items'] = openstation_ai_strip_wp_schema_keys( $items );
651 981 }
652 982 }
653 983
654 984 if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
655 - $schema['additionalProperties'] = desktop_mode_ai_strip_wp_schema_keys( $schema['additionalProperties'] );
985 + $schema['additionalProperties'] = openstation_ai_strip_wp_schema_keys( $schema['additionalProperties'] );
656 986 }
657 987
658 988 foreach ( array( 'oneOf', 'allOf', 'anyOf' ) as $combinator ) {
659 989 if ( isset( $schema[ $combinator ] ) && is_array( $schema[ $combinator ] ) ) {
@@ -658,9 +988,9 @@
658 988 foreach ( array( 'oneOf', 'allOf', 'anyOf' ) as $combinator ) {
659 989 if ( isset( $schema[ $combinator ] ) && is_array( $schema[ $combinator ] ) ) {
660 990 foreach ( $schema[ $combinator ] as $i => $sub ) {
661 991 if ( is_array( $sub ) ) {
662 - $schema[ $combinator ][ $i ] = desktop_mode_ai_strip_wp_schema_keys( $sub );
992 + $schema[ $combinator ][ $i ] = openstation_ai_strip_wp_schema_keys( $sub );
663 993 }
664 994 }
665 995 }
666 996 }
@@ -684,23 +1014,12 @@
684 1014 *
685 1015 * @param string $query User's natural-language search.
686 1016 * @param string|null $initial_tool Tool name to resume from, or null for fresh search.
687 1017 * @param int $start_offset Offset to resume from (0 for fresh).
688 - * @param callable|null $on_progress Optional progress emitter for SSE ticks.
689 1018 * @param array $extra Extensibility context (command tools, prompt overrides, …).
690 1019 * @return array|WP_Error
691 1020 */
692 -function desktop_mode_ai_run_search( $query, $initial_tool = null, $start_offset = 0, $on_progress = null, array $extra = array() ) {
693 - /**
694 - * Progress emitter — sends a tick to the caller if they provided a
695 - * callable; no-op otherwise. Callers use this to render real-time
696 - * status to the user via SSE.
697 - */
698 - $emit = static function ( array $event ) use ( $on_progress ) {
699 - if ( is_callable( $on_progress ) ) {
700 - $on_progress( $event );
701 - }
702 - };
1021 +function openstation_ai_run_search( $query, $initial_tool = null, $start_offset = 0, array $extra = array() ) {
703 1022 $start_offset = max( 0, (int) $start_offset );
704 1023 $search_tools = array( 'search_posts', 'search_pages', 'search_comments', 'search_comments_by_post' );
705 1024 $valid_tools = array_merge(
706 1025 $search_tools,
@@ -712,11 +1031,11 @@
712 1031 // tools from the server-side registry, system-prompt overrides, and a
713 1032 // per-call request_id for observability fanout.
714 1033 // -----------------------------------------------------------------------
715 1034 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
716 - $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']
717 1036 ? (string) $extra['request_id']
718 - : ( 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 ) );
719 1038 $command_tools_raw = isset( $extra['command_tools'] ) && is_array( $extra['command_tools'] ) ? $extra['command_tools'] : array();
720 1039 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
721 1040 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
722 1041 ? (string) $extra['system_prompt_mode']
@@ -724,10 +1043,10 @@
724 1043
725 1044 /**
726 1045 * Fires once per `/ai/search` invocation, after validation and
727 1046 * before any the provider call. First anchor in the observability trio
728 - * (`desktop_mode_ai_search_started` / `desktop_mode_ai_tool_called`
729 - * / `desktop_mode_ai_search_completed`).
1047 + * (`openstation_ai_search_started` / `openstation_ai_tool_called`
1048 + * / `openstation_ai_search_completed`).
730 1049 *
731 1050 * @param array $context {
732 1051 * @type string $query User query.
733 1052 * @type int $user_id
@@ -734,9 +1053,9 @@
734 1053 * @type string $request_id UUID correlating the whole run.
735 1054 * }
736 1055 */
737 1056 do_action(
738 - 'desktop_mode_ai_search_started',
1057 + 'openstation_ai_search_started',
739 1058 array(
740 1059 'query' => $query,
741 1060 'user_id' => $user_id,
742 1061 'request_id' => $request_id,
@@ -742,9 +1061,9 @@
742 1061 'request_id' => $request_id,
743 1062 )
744 1063 );
745 1064
746 - 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 ) ) {
747 1066 $initial_tool = null;
748 1067 }
749 1068
750 1069 // When resuming a previous exhausted run, prime the model with the
@@ -750,9 +1069,9 @@
750 1069 // When resuming a previous exhausted run, prime the model with the
751 1070 // starting position so it doesn't waste iterations on already-searched
752 1071 // content.
753 1072 $continuation_note = '';
754 - if ( $initial_tool !== null && ( $start_offset > 0 || $initial_tool !== 'search_posts' ) ) {
1073 + if ( null !== $initial_tool && ( $start_offset > 0 || 'search_posts' !== $initial_tool ) ) {
755 1074 $continuation_note = sprintf(
756 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.",
757 1076 $initial_tool,
758 1077 $start_offset
@@ -799,9 +1118,9 @@
799 1118
800 1119 // -----------------------------------------------------------------------
801 1120 // System-prompt extensibility. All three layers — appendix filter,
802 1121 // client override (append/replace with capability gate), and final
803 - // transform — live in `desktop_mode_ai_compose_instructions()` so the
1122 + // transform — live in `openstation_ai_compose_instructions()` so the
804 1123 // primary run and the follow-up leg stay in lockstep. See the
805 1124 // helper for the order of application; the filter docblocks at its
806 1125 // `apply_filters()` call sites carry the public contract on each
807 1126 // extension point.
@@ -811,12 +1130,15 @@
811 1130 'user_id' => $user_id,
812 1131 'request_id' => $request_id,
813 1132 );
814 1133
815 - $instructions = desktop_mode_ai_compose_instructions(
1134 + $instructions = openstation_ai_compose_instructions(
816 1135 $instructions,
817 1136 $prompt_context,
818 - array( 'text' => $system_prompt_text, 'mode' => $system_prompt_mode )
1137 + array(
1138 + 'text' => $system_prompt_text,
1139 + 'mode' => $system_prompt_mode,
1140 + )
819 1141 );
820 1142
821 1143 // -----------------------------------------------------------------------
822 1144 // Tool assembly — built-in search/navigation abilities + client-supplied
@@ -830,15 +1152,15 @@
830 1152 // -----------------------------------------------------------------------
831 1153 $ability_by_tool = array();
832 1154 $builtin_tools = array();
833 1155
834 - foreach ( desktop_mode_ai_search_ability_names() as $ability_name ) {
1156 + foreach ( openstation_ai_search_ability_names() as $ability_name ) {
835 1157 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
836 1158 if ( ! $ability instanceof WP_Ability ) {
837 1159 continue;
838 1160 }
839 1161
840 - $tool_name = desktop_mode_ai_ability_tool_name( $ability_name );
1162 + $tool_name = openstation_ai_ability_tool_name( $ability_name );
841 1163 $ability_by_tool[ $tool_name ] = $ability_name;
842 1164 $valid_tools[] = $tool_name;
843 1165
844 1166 $input_schema = $ability->get_input_schema();
@@ -847,9 +1169,12 @@
847 1169 'name' => $tool_name,
848 1170 'description' => (string) $ability->get_description(),
849 1171 'parameters' => ! empty( $input_schema )
850 1172 ? $input_schema
851 - : array( 'type' => 'object', 'properties' => (object) array() ),
1173 + : array(
1174 + 'type' => 'object',
1175 + 'properties' => (object) array(),
1176 + ),
852 1177 );
853 1178 }
854 1179
855 1180 // Command tools — namespaced as `command_<slug>` on the server so
@@ -863,9 +1188,9 @@
863 1188 if ( ! is_array( $cmd ) ) {
864 1189 continue;
865 1190 }
866 1191 $slug = isset( $cmd['slug'] ) ? (string) $cmd['slug'] : '';
867 - if ( $slug === '' || ! preg_match( '/^[a-z0-9_\-]+$/', $slug ) ) {
1192 + if ( '' === $slug || ! preg_match( '/^[a-z0-9_\-]+$/', $slug ) ) {
868 1193 continue;
869 1194 }
870 1195 /**
871 1196 * Per-tool filter on the client-supplied command list. Return
@@ -877,12 +1202,15 @@
877 1202 * @param string $slug Command slug.
878 1203 * @param array $context { user_id, request_id }.
879 1204 */
880 1205 $allowed = apply_filters(
881 - 'desktop_mode_ai_command_allowed',
1206 + 'openstation_ai_command_allowed',
882 1207 $cmd,
883 1208 $slug,
884 - array( 'user_id' => $user_id, 'request_id' => $request_id )
1209 + array(
1210 + 'user_id' => $user_id,
1211 + 'request_id' => $request_id,
1212 + )
885 1213 );
886 1214 if ( false === $allowed || ! is_array( $allowed ) ) {
887 1215 continue;
888 1216 }
@@ -896,13 +1224,13 @@
896 1224 'type' => 'function',
897 1225 'name' => $tool_name,
898 1226 'description' => trim( $label . ( '' !== $description ? ' — ' . $description : '' ) ),
899 1227 'parameters' => array(
900 - 'type' => 'object',
901 - 'properties' => array(
1228 + 'type' => 'object',
1229 + 'properties' => array(
902 1230 'args' => array(
903 1231 'type' => 'string',
904 - 'description' => $hint !== ''
1232 + 'description' => '' !== $hint
905 1233 ? sprintf( 'Arguments for this command. Hint: %s', $hint )
906 1234 : 'Arguments for this command. Leave empty when the command takes none.',
907 1235 ),
908 1236 ),
@@ -920,11 +1248,14 @@
920 1248 * @param array $command_defs Command tool definitions.
921 1249 * @param array $context { user_id, request_id }.
922 1250 */
923 1251 $command_defs = (array) apply_filters(
924 - 'desktop_mode_ai_command_tools',
1252 + 'openstation_ai_command_tools',
925 1253 $command_defs,
926 - array( 'user_id' => $user_id, 'request_id' => $request_id )
1254 + array(
1255 + 'user_id' => $user_id,
1256 + 'request_id' => $request_id,
1257 + )
927 1258 );
928 1259
929 1260 $tools = array_merge( $builtin_tools, $command_defs );
930 1261
@@ -936,11 +1267,15 @@
936 1267 * @param array $tools Full the provider tool definitions array.
937 1268 * @param array $context { user_id, request_id, query }.
938 1269 */
939 1270 $tools = (array) apply_filters(
940 - 'desktop_mode_ai_tools',
1271 + 'openstation_ai_tools',
941 1272 $tools,
942 - array( 'user_id' => $user_id, 'request_id' => $request_id, 'query' => $query )
1273 + array(
1274 + 'user_id' => $user_id,
1275 + 'request_id' => $request_id,
1276 + 'query' => $query,
1277 + )
943 1278 );
944 1279
945 1280 // Normalize every tool's schema onto the provider-supported subset, AFTER the
946 1281 // filter so it covers the complete list the provider will receive — built-in
@@ -950,9 +1285,9 @@
950 1285 // arguments against their real schema in execute(). Idempotent, so a plugin
951 1286 // that already normalizes on the filter above is unaffected.
952 1287 foreach ( $tools as $ti => $tool ) {
953 1288 if ( is_array( $tool ) && isset( $tool['parameters'] ) ) {
954 - $tools[ $ti ]['parameters'] = desktop_mode_ai_normalize_tool_schema( $tool['parameters'] );
1289 + $tools[ $ti ]['parameters'] = openstation_ai_normalize_tool_schema( $tool['parameters'] );
955 1290 }
956 1291 }
957 1292
958 1293 // Widen the permitted-tools list with the command tools — the agent loop
@@ -963,12 +1298,10 @@
963 1298 $valid_tools[] = (string) $def['name'];
964 1299 }
965 1300 }
966 1301
967 - $answer_schema = desktop_mode_ai_search_answer_schema();
1302 + $answer_schema = openstation_ai_search_answer_schema();
968 1303
969 - $emit( array( 'phase' => 'start', 'message' => 'Thinking about your question…' ) );
970 -
971 1304 // -----------------------------------------------------------------------
972 1305 // First call — user query as the sole message, instructions as system
973 1306 // guidance. Generation routes through the WordPress AI Client; the tools
974 1307 // are advertised as function declarations and dispatched by this loop.
@@ -973,12 +1306,17 @@
973 1306 // guidance. Generation routes through the WordPress AI Client; the tools
974 1307 // are advertised as function declarations and dispatched by this loop.
975 1308 // The full ordered conversation is rebuilt and re-sent each turn.
976 1309 // -----------------------------------------------------------------------
977 - $messages = array( desktop_mode_ai_user_text_message( $query ) );
1310 + $messages = array( openstation_ai_user_text_message( $query ) );
978 1311
979 - $turn = desktop_mode_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
1312 + $generation_context = array(
1313 + 'source' => 'ai-copilot/search',
1314 + 'request_id' => $request_id,
1315 + );
980 1316
1317 + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1318 +
981 1319 if ( is_wp_error( $turn ) ) {
982 1320 return $turn;
983 1321 }
984 1322
@@ -987,10 +1325,14 @@
987 1325 $last_has_more = true;
988 1326 $iterations = 0;
989 1327
990 1328 // Accumulate token usage across every turn and remember the last model the
991 - // AI Client resolved, for the `desktop_mode_ai_search_completed` payload.
992 - $total_usage = array( 'prompt' => 0, 'completion' => 0, 'total' => 0 );
1329 + // AI Client resolved, for the `openstation_ai_search_completed` payload.
1330 + $total_usage = array(
1331 + 'prompt' => 0,
1332 + 'completion' => 0,
1333 + 'total' => 0,
1334 + );
993 1335 $last_model = null;
994 1336 $accrue_usage = static function ( $turn ) use ( &$total_usage, &$last_model ) {
995 1337 if ( ! is_array( $turn ) ) {
996 1338 return;
@@ -1010,27 +1352,22 @@
1010 1352 // Agentic loop — each iteration either executes tool calls or returns
1011 1353 // the final answer. The full ordered conversation (user query, assistant
1012 1354 // turns, tool results) is accumulated in $messages and re-sent each turn.
1013 1355 // -----------------------------------------------------------------------
1014 - for ( $i = 0; $i < DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS; $i++ ) {
1356 + for ( $i = 0; $i < OPENSTATION_AI_SEARCH_MAX_ITERATIONS; $i++ ) {
1015 1357 $function_calls = is_array( $turn['function_calls'] ?? null ) ? $turn['function_calls'] : array();
1016 1358
1017 1359 // No tool calls in this response → final answer.
1018 1360 if ( empty( $function_calls ) ) {
1019 - $emit( array( 'phase' => 'composing', 'message' => 'Putting together your answer…' ) );
1020 - $text = $turn['text'] ?? null;
1021 - if ( ! is_string( $text ) ) {
1022 - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1023 - error_log( '[WP Desktop Mode AI] AI Client returned no text in the final turn.' );
1024 - return new WP_Error(
1025 - 'desktop_mode_ai_empty',
1026 - 'The AI provider returned no text in the final turn.'
1027 - );
1028 - }
1361 + // A toolless turn with no extractable text never reaches here:
1362 + // openstation_ai_client_generate() returns
1363 + // `openstation_ai_empty_answer` for that case, handled with the
1364 + // other generation errors above.
1365 + $text = (string) ( $turn['text'] ?? '' );
1029 1366
1030 1367 $answer = json_decode( $text, true );
1031 1368 if ( ! is_array( $answer ) ) {
1032 - return new WP_Error( 'desktop_mode_ai_result_parse', 'Could not parse structured search answer.' );
1369 + return new WP_Error( 'openstation_ai_result_parse', __( 'Could not parse structured search answer.', 'desktop-mode' ) );
1033 1370 }
1034 1371
1035 1372 $answer_type = isset( $answer['answer_type'] ) && in_array( $answer['answer_type'], array( 'entity', 'navigation', 'chat' ), true )
1036 1373 ? (string) $answer['answer_type']
@@ -1044,9 +1381,9 @@
1044 1381 ? $answer['admin_links'] : null;
1045 1382
1046 1383 $entity = null;
1047 1384 if ( 'entity' === $answer_type && $entity_id && $entity_type ) {
1048 - $entity = desktop_mode_ai_search_build_entity( $entity_type, $entity_id );
1385 + $entity = openstation_ai_search_build_entity( $entity_type, $entity_id );
1049 1386 }
1050 1387
1051 1388 $final = array(
1052 1389 'answer_type' => $answer_type,
@@ -1067,15 +1404,19 @@
1067 1404 * @param array $answer Final answer payload.
1068 1405 * @param array $context { query, user_id, request_id }.
1069 1406 */
1070 1407 $final = (array) apply_filters(
1071 - 'desktop_mode_ai_answer',
1408 + 'openstation_ai_answer',
1072 1409 $final,
1073 - array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1410 + array(
1411 + 'query' => $query,
1412 + 'user_id' => $user_id,
1413 + 'request_id' => $request_id,
1414 + )
1074 1415 );
1075 1416
1076 1417 do_action(
1077 - 'desktop_mode_ai_search_completed',
1418 + 'openstation_ai_search_completed',
1078 1419 array(
1079 1420 'query' => $query,
1080 1421 'user_id' => $user_id,
1081 1422 'request_id' => $request_id,
@@ -1101,10 +1442,10 @@
1101 1442 $command_tool_call = null;
1102 1443 foreach ( $function_calls as $fc ) {
1103 1444 $name = (string) ( $fc['name'] ?? '' );
1104 1445 if ( isset( $command_tools_by_name[ $name ] ) ) {
1105 - $raw = json_decode( $fc['arguments'] ?? '{}', true );
1106 - $decoded = is_array( $raw ) ? $raw : array();
1446 + $raw = json_decode( $fc['arguments'] ?? '{}', true );
1447 + $decoded = is_array( $raw ) ? $raw : array();
1107 1448 $command_tool_call = array(
1108 1449 'slug' => $command_tools_by_name[ $name ]['slug'],
1109 1450 'args' => isset( $decoded['args'] ) ? (string) $decoded['args'] : '',
1110 1451 );
@@ -1110,11 +1451,11 @@
1110 1451 );
1111 1452 break;
1112 1453 }
1113 1454 }
1114 - if ( $command_tool_call !== null ) {
1455 + if ( null !== $command_tool_call ) {
1115 1456 do_action(
1116 - 'desktop_mode_ai_tool_called',
1457 + 'openstation_ai_tool_called',
1117 1458 array(
1118 1459 'tool_name' => 'command_' . $command_tool_call['slug'],
1119 1460 'args' => array( 'args' => $command_tool_call['args'] ),
1120 1461 'user_id' => $user_id,
@@ -1134,15 +1475,19 @@
1134 1475 'request_id' => $request_id,
1135 1476 );
1136 1477
1137 1478 $final = (array) apply_filters(
1138 - 'desktop_mode_ai_answer',
1479 + 'openstation_ai_answer',
1139 1480 $final,
1140 - array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1481 + array(
1482 + 'query' => $query,
1483 + 'user_id' => $user_id,
1484 + 'request_id' => $request_id,
1485 + )
1141 1486 );
1142 1487
1143 1488 do_action(
1144 - 'desktop_mode_ai_search_completed',
1489 + 'openstation_ai_search_completed',
1145 1490 array(
1146 1491 'query' => $query,
1147 1492 'user_id' => $user_id,
1148 1493 'request_id' => $request_id,
@@ -1157,9 +1502,9 @@
1157 1502 }
1158 1503
1159 1504 // Execute each tool call and collect results as
1160 1505 // `{ call_id, name, response }` — turned into FunctionResponse parts
1161 - // for the next turn by desktop_mode_ai_tool_result_message().
1506 + // for the next turn by openstation_ai_tool_result_message().
1162 1507 $tool_outputs = array();
1163 1508 foreach ( $function_calls as $fc ) {
1164 1509 $tool_name = $fc['name'] ?? '';
1165 1510 $call_id = $fc['call_id'] ?? '';
@@ -1176,16 +1521,10 @@
1176 1521 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1177 1522 $args = is_array( $raw ) ? $raw : array();
1178 1523 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
1179 1524
1180 - $emit( array(
1181 - 'phase' => 'tool_call',
1182 - 'tool' => $tool_name,
1183 - 'message' => desktop_mode_ai_progress_message( $tool_name ),
1184 - ) );
1185 -
1186 1525 do_action(
1187 - 'desktop_mode_ai_tool_called',
1526 + 'openstation_ai_tool_called',
1188 1527 array(
1189 1528 'tool_name' => $tool_name,
1190 1529 'args' => $args,
1191 1530 'user_id' => $user_id,
@@ -1204,14 +1543,14 @@
1204 1543 // there's no schema; otherwise hand over the decoded args.
1205 1544 $input = empty( $ability->get_input_schema() ) ? null : $args;
1206 1545 $result = $ability->execute( $input );
1207 1546 } else {
1208 - $result = new WP_Error( 'desktop_mode_ai_unknown_ability', sprintf( 'Ability for tool "%s" is unavailable.', $tool_name ) );
1547 + $result = new WP_Error( 'openstation_ai_unknown_ability', sprintf( 'Ability for tool "%s" is unavailable.', $tool_name ) );
1209 1548 }
1210 1549
1211 1550 if ( is_wp_error( $result ) ) {
1212 1551 do_action(
1213 - 'desktop_mode_ai_search_error',
1552 + 'openstation_ai_search_error',
1214 1553 array(
1215 1554 'stage' => 'tool_execute',
1216 1555 'tool_name' => $tool_name,
1217 1556 'error' => $result->get_error_code(),
@@ -1242,13 +1581,16 @@
1242 1581 * @param array $args Decoded args from the call.
1243 1582 * @param array $context { user_id, request_id }.
1244 1583 */
1245 1584 $batch = (array) apply_filters(
1246 - 'desktop_mode_ai_tool_result',
1585 + 'openstation_ai_tool_result',
1247 1586 $batch,
1248 1587 $tool_name,
1249 1588 $args,
1250 - array( 'user_id' => $user_id, 'request_id' => $request_id )
1589 + array(
1590 + 'user_id' => $user_id,
1591 + 'request_id' => $request_id,
1592 + )
1251 1593 );
1252 1594
1253 1595 $tool_outputs[] = array(
1254 1596 'call_id' => $call_id,
@@ -1256,16 +1598,16 @@
1256 1598 'response' => $batch,
1257 1599 );
1258 1600 }
1259 1601
1260 - $iterations++;
1602 + ++$iterations;
1261 1603
1262 1604 // Next turn — append the assistant's tool-call turn and our tool
1263 1605 // results to the conversation, then regenerate with the full history.
1264 1606 $messages[] = $turn['message'];
1265 - $messages[] = desktop_mode_ai_tool_result_message( $tool_outputs );
1607 + $messages[] = openstation_ai_tool_result_message( $tool_outputs );
1266 1608
1267 - $turn = desktop_mode_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
1609 + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1268 1610
1269 1611 if ( is_wp_error( $turn ) ) {
1270 1612 return $turn;
1271 1613 }
@@ -1276,28 +1618,27 @@
1276 1618 // Budget exhausted before a final answer.
1277 1619 // -----------------------------------------------------------------------
1278 1620 $continue = null;
1279 1621 if ( $last_has_more ) {
1280 - $next_offset = $last_offset + DESKTOP_MODE_AI_SEARCH_BATCH_SIZE;
1622 + $next_offset = $last_offset + OPENSTATION_AI_SEARCH_BATCH_SIZE;
1281 1623 // `search_comments_by_post` cannot resume — the continue payload
1282 1624 // carries no post_id — so fall back to plain comment search,
1283 - // keeping `tool` inside desktop_mode_ai_search_resumable_tools().
1625 + // keeping `tool` inside openstation_ai_search_resumable_tools().
1284 1626 $resume_tool = 'search_comments_by_post' === $last_tool ? 'search_comments' : $last_tool;
1285 - $type_label = str_replace( 'search_', '', $resume_tool ) . 's';
1286 1627 $continue = array(
1287 1628 'tool' => $resume_tool,
1288 1629 'entity_type' => rtrim( str_replace( 'search_', '', $resume_tool ), 's' ),
1289 1630 'offset' => $next_offset,
1290 - 'label' => sprintf( 'Continue searching in %s (from item %d)', $type_label, $next_offset + 1 ),
1631 + 'label' => openstation_ai_continue_label( $resume_tool, $next_offset + 1 ),
1291 1632 );
1292 1633 }
1293 1634
1294 1635 $final = array(
1295 1636 'answer_type' => 'chat',
1296 - 'message' => 'I searched 100 items without finding a clear match. Want me to keep looking further?',
1637 + 'message' => __( 'I searched 100 items without finding a clear match. Want me to keep looking further?', 'desktop-mode' ),
1297 1638 'entity' => null,
1298 1639 'admin_links' => null,
1299 - 'iterations' => DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS,
1640 + 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1300 1641 'exhausted' => ! $last_has_more,
1301 1642 'continue' => $continue,
1302 1643 'request_id' => $request_id,
1303 1644 );
@@ -1302,21 +1643,25 @@
1302 1643 'request_id' => $request_id,
1303 1644 );
1304 1645
1305 1646 $final = (array) apply_filters(
1306 - 'desktop_mode_ai_answer',
1647 + 'openstation_ai_answer',
1307 1648 $final,
1308 - array( 'query' => $query, 'user_id' => $user_id, 'request_id' => $request_id )
1649 + array(
1650 + 'query' => $query,
1651 + 'user_id' => $user_id,
1652 + 'request_id' => $request_id,
1653 + )
1309 1654 );
1310 1655
1311 1656 do_action(
1312 - 'desktop_mode_ai_search_completed',
1657 + 'openstation_ai_search_completed',
1313 1658 array(
1314 1659 'query' => $query,
1315 1660 'user_id' => $user_id,
1316 1661 'request_id' => $request_id,
1317 1662 'answer_type' => 'chat',
1318 - 'iterations' => DESKTOP_MODE_AI_SEARCH_MAX_ITERATIONS,
1663 + 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1319 1664 'usage' => $total_usage,
1320 1665 'model' => $last_model,
1321 1666 )
1322 1667 );
@@ -1331,16 +1676,16 @@
1331 1676 * keeps the voice consistent across legs and removes a class of drift
1332 1677 * bug where the two paths's prompt-assembly drifts apart.
1333 1678 *
1334 1679 * Applies three layers in order:
1335 - * 1. `desktop_mode_ai_system_prompt_appendix` — stacking filter;
1680 + * 1. `openstation_ai_system_prompt_appendix` — stacking filter;
1336 1681 * every plugin's return is concatenated.
1337 1682 * 2. Client override — `system_prompt_text` + `system_prompt_mode`.
1338 1683 * `append` always allowed; `replace` gated on
1339 - * `desktop_mode_ai_system_prompt_replace_capability`. Non-permitted
1684 + * `openstation_ai_system_prompt_replace_capability`. Non-permitted
1340 1685 * `replace` downgrades to `append` so the caller's text is
1341 1686 * preserved rather than dropped.
1342 - * 3. `desktop_mode_ai_system_prompt` — final transform pass.
1687 + * 3. `openstation_ai_system_prompt` — final transform pass.
1343 1688 *
1344 1689 * @internal
1345 1690 *
1346 1691 * @param string $core Built-in instructions for this phase
@@ -1349,9 +1694,9 @@
1349 1694 * @param array $client { text, mode } client override; either field
1350 1695 * empty means no override.
1351 1696 * @return string Composed system prompt.
1352 1697 */
1353 -function desktop_mode_ai_compose_instructions( $core, array $context, array $client = array() ) {
1698 +function openstation_ai_compose_instructions( $core, array $context, array $client = array() ) {
1354 1699 $instructions = (string) $core;
1355 1700 $user_id = isset( $context['user_id'] ) ? (int) $context['user_id'] : 0;
1356 1701
1357 1702 $client_text = isset( $client['text'] ) && is_string( $client['text'] ) ? $client['text'] : '';
@@ -1358,9 +1703,9 @@
1358 1703 $client_mode = isset( $client['mode'] ) && in_array( $client['mode'], array( 'append', 'replace' ), true )
1359 1704 ? (string) $client['mode']
1360 1705 : 'append';
1361 1706
1362 - $ctx_for_filter = $context;
1707 + $ctx_for_filter = $context;
1363 1708 $ctx_for_filter['client_override'] = '' !== $client_text ? $client_mode : null;
1364 1709
1365 1710 /**
1366 1711 * Short-circuit extension — appended to the built-in instructions
@@ -1371,9 +1716,9 @@
1371 1716 *
1372 1717 * @param string $appendix Accumulated appendix. Default empty.
1373 1718 * @param array $context { query, user_id, request_id, client_override, phase? }.
1374 1719 */
1375 - $server_appendix = (string) apply_filters( 'desktop_mode_ai_system_prompt_appendix', '', $ctx_for_filter );
1720 + $server_appendix = (string) apply_filters( 'openstation_ai_system_prompt_appendix', '', $ctx_for_filter );
1376 1721 if ( '' !== $server_appendix ) {
1377 1722 $instructions .= "\n\n" . $server_appendix;
1378 1723 }
1379 1724
@@ -1388,9 +1733,9 @@
1388 1733 * @param string $capability Default `manage_options`.
1389 1734 * @param array $context
1390 1735 */
1391 1736 $required_cap = (string) apply_filters(
1392 - 'desktop_mode_ai_system_prompt_replace_capability',
1737 + 'openstation_ai_system_prompt_replace_capability',
1393 1738 'manage_options',
1394 1739 $ctx_for_filter
1395 1740 );
1396 1741 if ( '' === $required_cap || ( $user_id > 0 && user_can( $user_id, $required_cap ) ) ) {
@@ -1411,9 +1756,9 @@
1411 1756 *
1412 1757 * @param string $instructions Composed system prompt.
1413 1758 * @param array $context
1414 1759 */
1415 - return (string) apply_filters( 'desktop_mode_ai_system_prompt', $instructions, $ctx_for_filter );
1760 + return (string) apply_filters( 'openstation_ai_system_prompt', $instructions, $ctx_for_filter );
1416 1761 }
1417 1762
1418 1763 /**
1419 1764 * Compose a natural-language reply describing the outcome of a
@@ -1420,15 +1765,15 @@
1420 1765 * client-dispatched command invocation.
1421 1766 *
1422 1767 * Called by the REST endpoint when the client sends `follow_up` —
1423 1768 * the second leg of the opt-in agentic flow triggered by
1424 - * `wp.desktop.ai.ask( q, { tools: 'aiCallable', followUp: true } )`.
1769 + * `wp.os.ai.ask( q, { tools: 'aiCallable', followUp: true } )`.
1425 1770 *
1426 1771 * Single-turn, no tools, no structured-output schema — the model
1427 1772 * sees the original query + a summary of what happened and writes a
1428 1773 * one/two-sentence reply in the voice of the system prompt. We reuse
1429 1774 * the same system-prompt pipeline as the main search so plugins
1430 - * appending instructions via `desktop_mode_ai_system_prompt_appendix`
1775 + * appending instructions via `openstation_ai_system_prompt_appendix`
1431 1776 * see consistent voice across the two legs.
1432 1777 *
1433 1778 * @param string $query Original user query.
1434 1779 * @param array $tool { slug, args } — what ran.
@@ -1434,21 +1779,21 @@
1434 1779 * @param array $tool { slug, args } — what ran.
1435 1780 * @param array $outcome Tool result payload. Opaque — JSON-encoded
1436 1781 * into the model's context so it can reason
1437 1782 * about whatever shape the plugin returned.
1438 - * @param array $extra Same shape as `desktop_mode_ai_run_search`'s
1783 + * @param array $extra Same shape as `openstation_ai_run_search`'s
1439 1784 * `$extra` — carries user_id, request_id,
1440 1785 * system-prompt overrides.
1441 1786 * @return array|WP_Error `{ answer_type: 'chat', message, … }` or error.
1442 1787 */
1443 -function desktop_mode_ai_run_followup( $query, array $tool, array $outcome, array $extra = array() ) {
1788 +function openstation_ai_run_followup( $query, array $tool, array $outcome, array $extra = array() ) {
1444 1789 $user_id = isset( $extra['user_id'] ) ? (int) $extra['user_id'] : get_current_user_id();
1445 - $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && $extra['request_id'] !== ''
1790 + $request_id = isset( $extra['request_id'] ) && is_string( $extra['request_id'] ) && '' !== $extra['request_id']
1446 1791 ? (string) $extra['request_id']
1447 - : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ) );
1792 + : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ) );
1448 1793
1449 1794 do_action(
1450 - 'desktop_mode_ai_search_started',
1795 + 'openstation_ai_search_started',
1451 1796 array(
1452 1797 'query' => $query,
1453 1798 'user_id' => $user_id,
1454 1799 'request_id' => $request_id,
@@ -1458,19 +1803,19 @@
1458 1803
1459 1804 // Mirror the main search's system-prompt layering so voice stays
1460 1805 // consistent between the two legs. We build a simpler core-
1461 1806 // instructions block — no tool guidance, since this run has none.
1462 - $instructions = "
1807 + $instructions = '
1463 1808 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.
1464 1809
1465 1810 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.
1466 1811
1467 1812 Rules:
1468 -- If the outcome looks successful, confirm plainly. Example: \"Done — your office light is on now.\"
1813 +- If the outcome looks successful, confirm plainly. Example: "Done — your office light is on now."
1469 1814 - 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.
1470 1815 - Do NOT recommend the user try something else unless the outcome explicitly suggests it.
1471 -- Do NOT describe the tool mechanism (\"I called command_turn_light\") — the user only cares about the real-world effect.
1472 -";
1816 +- Do NOT describe the tool mechanism ("I called command_turn_light") — the user only cares about the real-world effect.
1817 +';
1473 1818
1474 1819 $system_prompt_text = isset( $extra['system_prompt_text'] ) && is_string( $extra['system_prompt_text'] ) ? $extra['system_prompt_text'] : '';
1475 1820 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1476 1821 ? (string) $extra['system_prompt_mode']
@@ -1475,9 +1820,9 @@
1475 1820 $system_prompt_mode = isset( $extra['system_prompt_mode'] ) && in_array( $extra['system_prompt_mode'], array( 'append', 'replace' ), true )
1476 1821 ? (string) $extra['system_prompt_mode']
1477 1822 : 'append';
1478 1823
1479 - $instructions = desktop_mode_ai_compose_instructions(
1824 + $instructions = openstation_ai_compose_instructions(
1480 1825 $instructions,
1481 1826 array(
1482 1827 'query' => $query,
1483 1828 'user_id' => $user_id,
@@ -1483,9 +1828,12 @@
1483 1828 'user_id' => $user_id,
1484 1829 'request_id' => $request_id,
1485 1830 'phase' => 'follow_up',
1486 1831 ),
1487 - array( 'text' => $system_prompt_text, 'mode' => $system_prompt_mode )
1832 + array(
1833 + 'text' => $system_prompt_text,
1834 + 'mode' => $system_prompt_mode,
1835 + )
1488 1836 );
1489 1837
1490 1838 $slug = isset( $tool['slug'] ) ? (string) $tool['slug'] : '';
1491 1839 $tool_args = isset( $tool['args'] ) ? (string) $tool['args'] : '';
@@ -1504,9 +1852,9 @@
1504 1852 // (Japanese / emoji / accented UTF-8) can't produce invalid JSON
1505 1853 // that the provider would reject. Falls back to byte-level substr when
1506 1854 // mbstring is unavailable (rare but possible on minimal PHP
1507 1855 // builds).
1508 - $max_outcome_len = (int) apply_filters( 'desktop_mode_ai_followup_outcome_max_chars', 4000 );
1856 + $max_outcome_len = (int) apply_filters( 'openstation_ai_followup_outcome_max_chars', 4000 );
1509 1857 if ( $max_outcome_len > 0 ) {
1510 1858 $has_mbstring = function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' );
1511 1859 $current_len = $has_mbstring
1512 1860 ? mb_strlen( $outcome_json, 'UTF-8' )
@@ -1511,9 +1859,9 @@
1511 1859 $current_len = $has_mbstring
1512 1860 ? mb_strlen( $outcome_json, 'UTF-8' )
1513 1861 : strlen( $outcome_json );
1514 1862 if ( $current_len > $max_outcome_len ) {
1515 - $outcome_json = $has_mbstring
1863 + $outcome_json = $has_mbstring
1516 1864 ? mb_substr( $outcome_json, 0, $max_outcome_len, 'UTF-8' )
1517 1865 : substr( $outcome_json, 0, $max_outcome_len );
1518 1866 $outcome_json .= '…[truncated]';
1519 1867 }
@@ -1527,28 +1875,41 @@
1527 1875 $outcome_json
1528 1876 );
1529 1877
1530 1878 do_action(
1531 - 'desktop_mode_ai_tool_called',
1879 + 'openstation_ai_tool_called',
1532 1880 array(
1533 1881 'tool_name' => 'followup_summarise',
1534 - 'args' => array( 'slug' => $slug, 'tool_args' => $tool_args ),
1882 + 'args' => array(
1883 + 'slug' => $slug,
1884 + 'tool_args' => $tool_args,
1885 + ),
1535 1886 'user_id' => $user_id,
1536 1887 'request_id' => $request_id,
1537 1888 )
1538 1889 );
1539 1890
1540 - $turn = desktop_mode_ai_client_generate(
1891 + $turn = openstation_ai_client_generate(
1541 1892 $user_id,
1542 - array( desktop_mode_ai_user_text_message( $user_message ) ),
1893 + array( openstation_ai_user_text_message( $user_message ) ),
1543 1894 array(), // no tools — we want a plain reply
1544 1895 null, // no JSON schema — free-form text
1545 - $instructions
1896 + $instructions,
1897 + array(
1898 + 'source' => 'ai-copilot/followup',
1899 + 'request_id' => $request_id,
1900 + )
1546 1901 );
1547 1902
1548 - if ( is_wp_error( $turn ) ) {
1903 + // `openstation_ai_empty_answer` is the one generation error this path
1904 + // deliberately absorbs: the command DID run, so a text-less summary turn
1905 + // degrades to the generic confirmation below instead of surfacing as a
1906 + // failure of the command itself.
1907 + $empty_answer = is_wp_error( $turn ) && 'openstation_ai_empty_answer' === $turn->get_error_code();
1908 +
1909 + if ( is_wp_error( $turn ) && ! $empty_answer ) {
1549 1910 do_action(
1550 - 'desktop_mode_ai_search_error',
1911 + 'openstation_ai_search_error',
1551 1912 array(
1552 1913 'code' => $turn->get_error_code(),
1553 1914 'message' => $turn->get_error_message(),
1554 1915 'data' => $turn->get_error_data(),
@@ -1559,9 +1920,9 @@
1559 1920 );
1560 1921 return $turn;
1561 1922 }
1562 1923
1563 - $text = $turn['text'] ?? null;
1924 + $text = $empty_answer ? null : ( $turn['text'] ?? null );
1564 1925 $fallback = false;
1565 1926 if ( ! is_string( $text ) || '' === trim( $text ) ) {
1566 1927 // Graceful degrade — if the provider returned nothing usable, fall
1567 1928 // back to a generic confirmation so the caller always has a
@@ -1581,14 +1942,17 @@
1581 1942 'iterations' => 1,
1582 1943 'exhausted' => false,
1583 1944 'continue' => null,
1584 1945 'request_id' => $request_id,
1585 - 'tool' => array( 'slug' => $slug, 'args' => $tool_args ),
1946 + 'tool' => array(
1947 + 'slug' => $slug,
1948 + 'args' => $tool_args,
1949 + ),
1586 1950 'fallback' => $fallback,
1587 1951 );
1588 1952
1589 1953 $final = (array) apply_filters(
1590 - 'desktop_mode_ai_answer',
1954 + 'openstation_ai_answer',
1591 1955 $final,
1592 1956 array(
1593 1957 'query' => $query,
1594 1958 'user_id' => $user_id,
@@ -1597,9 +1961,9 @@
1597 1961 )
1598 1962 );
1599 1963
1600 1964 do_action(
1601 - 'desktop_mode_ai_search_completed',
1965 + 'openstation_ai_search_completed',
1602 1966 array(
1603 1967 'query' => $query,
1604 1968 'user_id' => $user_id,
1605 1969 'request_id' => $request_id,
@@ -1619,18 +1983,18 @@
1619 1983
1620 1984 /**
1621 1985 * Registers the AI search REST route.
1622 1986 */
1623 -function desktop_mode_register_ai_search_rest_route() {
1987 +function openstation_register_ai_search_rest_route() {
1624 1988 register_rest_route(
1625 1989 'desktop-mode/v1',
1626 1990 '/ai/search',
1627 1991 array(
1628 1992 'methods' => WP_REST_Server::CREATABLE,
1629 - 'callback' => 'desktop_mode_rest_ai_search',
1630 - 'permission_callback' => 'desktop_mode_rest_ai_search_permission',
1993 + 'callback' => 'openstation_rest_ai_search',
1994 + 'permission_callback' => 'openstation_rest_ai_search_permission',
1631 1995 'args' => array(
1632 - 'query' => array(
1996 + 'query' => array(
1633 1997 'required' => true,
1634 1998 'type' => 'string',
1635 1999 'sanitize_callback' => 'sanitize_text_field',
1636 2000 'validate_callback' => static function ( $v ) {
@@ -1640,18 +2004,18 @@
1640 2004 // `resume_tool` + `start_offset` are only set when the
1641 2005 // client is continuing a previous search from the `continue`
1642 2006 // object returned by an exhausted run. Fresh searches leave
1643 2007 // both unset — the agent picks tools from query semantics.
1644 - 'resume_tool' => array(
2008 + 'resume_tool' => array(
1645 2009 'required' => false,
1646 2010 'type' => array( 'string', 'null' ),
1647 2011 'default' => null,
1648 2012 'sanitize_callback' => static function ( $v ) {
1649 - return in_array( $v, desktop_mode_ai_search_resumable_tools(), true )
2013 + return in_array( $v, openstation_ai_search_resumable_tools(), true )
1650 2014 ? $v : null;
1651 - },
2015 + },
1652 2016 ),
1653 - 'start_offset' => array(
2017 + 'start_offset' => array(
1654 2018 'required' => false,
1655 2019 'type' => 'integer',
1656 2020 'default' => 0,
1657 2021 'sanitize_callback' => 'absint',
@@ -1660,9 +2024,9 @@
1660 2024 // opted in as AI tools. Each entry: { slug, label, description?, hint? }.
1661 2025 // The slug is namespaced server-side as `command_<slug>`
1662 2026 // and any tool_call the model emits with that name short-
1663 2027 // circuits back to the client for local dispatch.
1664 - 'command_tools' => array(
2028 + 'command_tools' => array(
1665 2029 'required' => false,
1666 2030 'type' => 'array',
1667 2031 'default' => array(),
1668 2032 'items' => array(
@@ -1675,12 +2039,12 @@
1675 2039 ),
1676 2040 ),
1677 2041 ),
1678 2042 // Free-form system-prompt override.
1679 - // mode: 'append' → concatenated onto the built-in prompt (safe for everyone)
1680 - // mode: 'replace' → replaces the built-in prompt entirely, gated on
1681 - // `desktop_mode_ai_system_prompt_replace_capability`
1682 - // (default `manage_options`).
2043 + // mode: 'append' → concatenated onto the built-in prompt (safe for everyone)
2044 + // mode: 'replace' → replaces the built-in prompt entirely, gated on
2045 + // `openstation_ai_system_prompt_replace_capability`
2046 + // (default `manage_options`).
1683 2047 'system_prompt_text' => array(
1684 2048 'required' => false,
1685 2049 'type' => 'string',
1686 2050 'default' => '',
@@ -1697,9 +2061,9 @@
1697 2061 // When present, the endpoint SKIPS the agent loop entirely
1698 2062 // and runs a single-turn "summarise this outcome" call
1699 2063 // through the provider instead. The client sends this on the
1700 2064 // second leg of `ask( q, { tools: 'aiCallable', followUp: true } )`.
1701 - 'follow_up' => array(
2065 + 'follow_up' => array(
1702 2066 'required' => false,
1703 2067 'type' => array( 'object', 'null' ),
1704 2068 'default' => null,
1705 2069 ),
@@ -1706,9 +2070,9 @@
1706 2070 ),
1707 2071 )
1708 2072 );
1709 2073 }
1710 -add_action( 'rest_api_init', 'desktop_mode_register_ai_search_rest_route' );
2074 +add_action( 'rest_api_init', 'openstation_register_ai_search_rest_route' );
1711 2075
1712 2076 /**
1713 2077 * Permission callback.
1714 2078 *
@@ -1713,28 +2077,37 @@
1713 2077 * Permission callback.
1714 2078 *
1715 2079 * @return bool|WP_Error
1716 2080 */
1717 -function desktop_mode_rest_ai_search_permission() {
2081 +function openstation_rest_ai_search_permission() {
1718 2082 if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
1719 2083 return new WP_Error(
1720 - 'desktop_mode_ai_forbidden',
1721 - 'You must be logged in to use the AI assistant.',
2084 + 'openstation_ai_forbidden',
2085 + __( 'You must be logged in to use the AI assistant.', 'desktop-mode' ),
1722 2086 array( 'status' => 403 )
1723 2087 );
1724 2088 }
1725 - if ( ! desktop_mode_ai_is_available() ) {
2089 + if ( ! openstation_ai_is_available() ) {
1726 2090 return new WP_Error(
1727 - 'desktop_mode_ai_unavailable',
1728 - 'The AI assistant is unavailable on this site.',
2091 + 'openstation_ai_unavailable',
2092 + __( 'The AI assistant is unavailable on this site.', 'desktop-mode' ),
1729 2093 array( 'status' => 503 )
1730 2094 );
1731 2095 }
1732 - if ( ! desktop_mode_ai_is_enabled( get_current_user_id() ) ) {
2096 + if ( ! openstation_ai_is_enabled( get_current_user_id() ) ) {
2097 + // The message names the tab for consumers that only get text (a REST
2098 + // client, `wp.os.ai.ask()`). `settings_tab` names it again as data,
2099 + // so the overlay can offer a one-click link without parsing prose.
1733 2100 return new WP_Error(
1734 - 'desktop_mode_ai_disabled',
1735 - 'The AI assistant is turned off. Enable it in OS Settings → Features.',
1736 - array( 'status' => 403 )
2101 + 'openstation_ai_disabled',
2102 + __(
2103 + 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.',
2104 + 'desktop-mode'
2105 + ),
2106 + array(
2107 + 'status' => 403,
2108 + 'settings_tab' => 'features',
2109 + )
1737 2110 );
1738 2111 }
1739 2112 return true;
1740 2113 }
@@ -1744,9 +2117,15 @@
1744 2117 *
1745 2118 * @param WP_REST_Request $request
1746 2119 * @return WP_REST_Response|WP_Error
1747 2120 */
1748 -function desktop_mode_rest_ai_search( WP_REST_Request $request ) {
2121 +function openstation_rest_ai_search( WP_REST_Request $request ) {
2122 + // The agent loop runs up to OPENSTATION_AI_SEARCH_MAX_ITERATIONS model
2123 + // round-trips, each with a tool call, which overruns a default 30s
2124 + // max_execution_time. The streaming endpoint used to raise this; it is
2125 + // the only path now, so it carries the limit.
2126 + @set_time_limit( 120 ); // phpcs:ignore
2127 +
1749 2128 $user_id = get_current_user_id();
1750 2129 $query = $request->get_param( 'query' );
1751 2130 $resume_tool = $request->get_param( 'resume_tool' );
1752 2131 $start_offset = $request->get_param( 'start_offset' );
@@ -1757,9 +2136,9 @@
1757 2136 }
1758 2137
1759 2138 $extra = array(
1760 2139 'user_id' => $user_id,
1761 - 'request_id' => function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'desktop_mode_ai_', true ),
2140 + 'request_id' => function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'openstation_ai_', true ),
1762 2141 'command_tools' => $command_tools,
1763 2142 'system_prompt_text' => (string) $request->get_param( 'system_prompt_text' ),
1764 2143 'system_prompt_mode' => (string) $request->get_param( 'system_prompt_mode' ),
1765 2144 );
@@ -1772,9 +2151,9 @@
1772 2151 * @param array $extra Extended context (mutable).
1773 2152 * @param array $core Core request params { query, resume_tool, start_offset }.
1774 2153 */
1775 2154 $extra = (array) apply_filters(
1776 - 'desktop_mode_ai_request',
2155 + 'openstation_ai_request',
1777 2156 $extra,
1778 2157 array(
1779 2158 'query' => $query,
1780 2159 'resume_tool' => $resume_tool,
@@ -1788,17 +2167,17 @@
1788 2167 $tool = $follow_up['tool'];
1789 2168 $outcome = isset( $follow_up['result'] )
1790 2169 ? ( is_array( $follow_up['result'] ) ? $follow_up['result'] : array( 'value' => $follow_up['result'] ) )
1791 2170 : array();
1792 - $result = desktop_mode_ai_run_followup( $query, $tool, $outcome, $extra );
2171 + $result = openstation_ai_run_followup( $query, $tool, $outcome, $extra );
1793 2172 } else {
1794 - $result = desktop_mode_ai_run_search( $query, $resume_tool, $start_offset, null, $extra );
2173 + $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, $extra );
1795 2174 }
1796 2175
1797 2176 if ( is_wp_error( $result ) ) {
1798 2177 $request_id = isset( $extra['request_id'] ) ? (string) $extra['request_id'] : '';
1799 2178 do_action(
1800 - 'desktop_mode_ai_search_error',
2179 + 'openstation_ai_search_error',
1801 2180 array(
1802 2181 'code' => $result->get_error_code(),
1803 2182 'message' => $result->get_error_message(),
1804 2183 'data' => $result->get_error_data(),
@@ -1823,11 +2202,11 @@
1823 2202 *
1824 2203 * @param string $query Search terms.
1825 2204 * @return array Tool result payload ready for the model.
1826 2205 */
1827 -function desktop_mode_ai_fetch_wporg_plugins( $query ) {
2206 +function openstation_ai_fetch_wporg_plugins( $query ) {
1828 2207 $query = trim( (string) $query );
1829 - if ( $query === '' ) {
2208 + if ( '' === $query ) {
1830 2209 return array(
1831 2210 'tool' => 'search_wporg_plugins',
1832 2211 'query' => '',
1833 2212 'results' => array(),
@@ -1837,9 +2216,9 @@
1837 2216 }
1838 2217
1839 2218 // Transient cache to protect the w.org API from repeated queries
1840 2219 // within the same conversation.
1841 - $cache_key = 'desktop_mode_ai_plugins_' . md5( strtolower( $query ) );
2220 + $cache_key = 'openstation_ai_plugins_' . md5( strtolower( $query ) );
1842 2221 $cached = get_transient( $cache_key );
1843 2222 if ( is_array( $cached ) ) {
1844 2223 return $cached;
1845 2224 }
@@ -1896,9 +2275,9 @@
1896 2275 // Normalise — plugins_api sometimes returns arrays, sometimes objects.
1897 2276 $p = (array) $p;
1898 2277
1899 2278 $slug = isset( $p['slug'] ) ? (string) $p['slug'] : '';
1900 - if ( $slug === '' ) {
2279 + if ( '' === $slug ) {
1901 2280 continue;
1902 2281 }
1903 2282
1904 2283 $icon = '';
@@ -1967,15 +2346,15 @@
1967 2346 *
1968 2347 * @param int $lines Number of lines to return (clamped 1-500 by caller).
1969 2348 * @return array
1970 2349 */
1971 -function desktop_mode_ai_fetch_error_log( $lines = 50 ) {
2350 +function openstation_ai_fetch_error_log( $lines = 50 ) {
1972 2351 $candidates = array();
1973 2352 if ( defined( 'WP_CONTENT_DIR' ) ) {
1974 2353 $candidates[] = WP_CONTENT_DIR . '/debug.log';
1975 2354 }
1976 2355 $ini_log = (string) ini_get( 'error_log' );
1977 - if ( $ini_log !== '' && 'syslog' !== $ini_log ) {
2356 + if ( '' !== $ini_log && 'syslog' !== $ini_log ) {
1978 2357 $candidates[] = $ini_log;
1979 2358 }
1980 2359
1981 2360 /**
@@ -1983,9 +2362,9 @@
1983 2362 * redirect errors somewhere non-standard can add their path here.
1984 2363 *
1985 2364 * @param string[] $candidates File paths, in probe order.
1986 2365 */
1987 - $candidates = (array) apply_filters( 'desktop_mode_ai_error_log_candidates', $candidates );
2366 + $candidates = (array) apply_filters( 'openstation_ai_error_log_candidates', $candidates );
1988 2367
1989 2368 $log_path = '';
1990 2369 foreach ( $candidates as $path ) {
1991 2370 if ( is_string( $path ) && is_file( $path ) && is_readable( $path ) ) {
@@ -1993,9 +2372,9 @@
1993 2372 break;
1994 2373 }
1995 2374 }
1996 2375
1997 - if ( $log_path === '' ) {
2376 + if ( '' === $log_path ) {
1998 2377 return array(
1999 2378 'tool' => 'get_php_error_log',
2000 2379 'log_available' => false,
2001 2380 'message' => 'No readable error log found. Enable WP_DEBUG_LOG in wp-config.php or set php_value error_log.',
@@ -2004,17 +2383,17 @@
2004 2383 'count' => 0,
2005 2384 );
2006 2385 }
2007 2386
2008 - $tail = desktop_mode_ai_tail_file( $log_path, $lines );
2387 + $tail = openstation_ai_tail_file( $log_path, $lines );
2009 2388
2010 2389 $entries = array();
2011 2390 foreach ( $tail as $line ) {
2012 2391 $line = trim( $line );
2013 - if ( $line === '' ) {
2392 + if ( '' === $line ) {
2014 2393 continue;
2015 2394 }
2016 - $entries[] = desktop_mode_ai_parse_log_line( $line );
2395 + $entries[] = openstation_ai_parse_log_line( $line );
2017 2396 }
2018 2397
2019 2398 return array(
2020 2399 'tool' => 'get_php_error_log',
@@ -2034,9 +2413,9 @@
2034 2413 *
2035 2414 * @param string $line
2036 2415 * @return array
2037 2416 */
2038 -function desktop_mode_ai_parse_log_line( $line ) {
2417 +function openstation_ai_parse_log_line( $line ) {
2039 2418 // Cap individual messages so a runaway stack trace doesn't balloon
2040 2419 // the payload sent to the provider.
2041 2420 $line = mb_substr( $line, 0, 600 );
2042 2421
@@ -2070,9 +2449,9 @@
2070 2449 * @param string $path Absolute path to the file.
2071 2450 * @param int $lines
2072 2451 * @return string[] Lines in original order (oldest first).
2073 2452 */
2074 -function desktop_mode_ai_tail_file( $path, $lines ) {
2453 +function openstation_ai_tail_file( $path, $lines ) {
2075 2454 if ( ! function_exists( 'WP_Filesystem' ) ) {
2076 2455 require_once ABSPATH . 'wp-admin/includes/file.php';
2077 2456 }
2078 2457 WP_Filesystem();
@@ -2092,116 +2471,4 @@
2092 2471 }
2093 2472
2094 2473 return array_slice( $all, -1 * ( $lines + 1 ) );
2095 2474 }
2096 -
2097 -// ---------------------------------------------------------------------------
2098 -// Streaming endpoint (Server-Sent Events)
2099 -//
2100 -// EventSource can't send POST or custom headers, so we ride admin-ajax.php
2101 -// which handles cookie-based auth natively. The nonce goes in the URL.
2102 -// Output buffering is forcibly disabled and every emit is flushed so the
2103 -// browser receives progress ticks in real time.
2104 -// ---------------------------------------------------------------------------
2105 -
2106 -/**
2107 - * admin-ajax handler for the streaming search endpoint.
2108 - *
2109 - * URL: /wp-admin/admin-ajax.php?action=desktop_mode_ai_search_stream
2110 - * &nonce=<rest_nonce>
2111 - * &query=<user question>
2112 - * &resume_tool=<search_posts|…> (optional)
2113 - * &start_offset=<int> (optional)
2114 - *
2115 - * Emits SSE events:
2116 - * data: { "event": "progress", "phase": "tool_call", "message": "…" }
2117 - * data: { "event": "done", "result": { … } }
2118 - * data: { "event": "error", "message": "…" }
2119 - */
2120 -function desktop_mode_ai_ajax_search_stream() {
2121 - $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
2122 - if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
2123 - status_header( 403 );
2124 - exit;
2125 - }
2126 - if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
2127 - status_header( 403 );
2128 - exit;
2129 - }
2130 - $user_id = get_current_user_id();
2131 - if ( ! desktop_mode_ai_is_available() ) {
2132 - status_header( 503 );
2133 - exit;
2134 - }
2135 - if ( ! desktop_mode_ai_is_enabled( $user_id ) ) {
2136 - status_header( 403 );
2137 - exit;
2138 - }
2139 -
2140 - $query = isset( $_GET['query'] ) ? sanitize_text_field( wp_unslash( $_GET['query'] ) ) : ''; // phpcs:ignore WordPress.Security
2141 - if ( trim( $query ) === '' ) {
2142 - status_header( 400 );
2143 - exit;
2144 - }
2145 -
2146 - $resume_tool = isset( $_GET['resume_tool'] ) ? sanitize_key( wp_unslash( $_GET['resume_tool'] ) ) : null; // phpcs:ignore WordPress.Security
2147 - $start_offset = isset( $_GET['start_offset'] ) ? absint( $_GET['start_offset'] ) : 0; // phpcs:ignore WordPress.Security
2148 - if ( $resume_tool !== null && ! in_array( $resume_tool, desktop_mode_ai_search_resumable_tools(), true ) ) {
2149 - $resume_tool = null;
2150 - }
2151 -
2152 - // SSE headers — tell nginx to stop buffering, tell the browser this is
2153 - // a persistent event stream.
2154 - header( 'Content-Type: text/event-stream; charset=utf-8' );
2155 - header( 'Cache-Control: no-cache, no-store, must-revalidate' );
2156 - header( 'X-Accel-Buffering: no' );
2157 - header( 'Connection: keep-alive' );
2158 -
2159 - // Let other requests from this user proceed (release session lock).
2160 - if ( session_status() === PHP_SESSION_ACTIVE ) {
2161 - session_write_close();
2162 - }
2163 -
2164 - // Kill any output buffers PHP set up, otherwise nothing flushes until
2165 - // the request ends — which defeats the whole point of streaming.
2166 - while ( ob_get_level() > 0 ) {
2167 - @ob_end_flush(); // phpcs:ignore
2168 - }
2169 - @ini_set( 'output_buffering', 'off' ); // phpcs:ignore
2170 - @ini_set( 'zlib.output_compression', 'off' ); // phpcs:ignore
2171 - @set_time_limit( 120 ); // phpcs:ignore
2172 -
2173 - $emit = static function ( array $payload ) {
2174 - echo 'data: ' . wp_json_encode( $payload ) . "\n\n";
2175 - @ob_flush(); // phpcs:ignore
2176 - flush();
2177 - };
2178 -
2179 - // Initial tick so the EventSource opens immediately and the JS can
2180 - // start showing "Thinking…" without waiting for the first model call.
2181 - $emit( array( 'event' => 'open' ) );
2182 -
2183 - $result = desktop_mode_ai_run_search(
2184 - $query,
2185 - $resume_tool,
2186 - $start_offset,
2187 - function ( $progress ) use ( $emit ) {
2188 - $emit( array_merge( array( 'event' => 'progress' ), $progress ) );
2189 - }
2190 - );
2191 -
2192 - if ( is_wp_error( $result ) ) {
2193 - $emit( array(
2194 - 'event' => 'error',
2195 - 'message' => $result->get_error_message(),
2196 - 'code' => $result->get_error_code(),
2197 - ) );
2198 - } else {
2199 - $emit( array(
2200 - 'event' => 'done',
2201 - 'result' => $result,
2202 - ) );
2203 - }
2204 -
2205 - exit;
2206 -}
2207 -add_action( 'wp_ajax_desktop_mode_ai_search_stream', 'desktop_mode_ai_ajax_search_stream' );