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