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