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