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