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