PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.11
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.11
1.1.11 1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 All 35 releases
← All changes | includes/ai-copilot/search.php +296 -261 1.0.1 → 1.1.11 View file →
@@ -59,186 +59,186 @@
59 59 */
60 60 function openstation_ai_get_admin_page_catalog() {
61 61 $catalog = array(
62 62 array(
63 - 'title' => 'Dashboard',
63 + 'title' => __( 'Dashboard', 'desktop-mode' ),
64 64 'url' => admin_url( 'index.php' ),
65 65 'icon' => 'dashicons-dashboard',
66 - 'description' => 'The main admin dashboard — activity, drafts, site overview.',
66 + 'description' => __( 'The main admin dashboard: activity, drafts, site overview.', 'desktop-mode' ),
67 67 ),
68 68 array(
69 - 'title' => 'All Posts',
69 + 'title' => __( 'All Posts', 'desktop-mode' ),
70 70 'url' => admin_url( 'edit.php' ),
71 71 'icon' => 'dashicons-admin-post',
72 - 'description' => 'List, edit, bulk-manage blog posts.',
72 + 'description' => __( 'List, edit, bulk-manage blog posts.', 'desktop-mode' ),
73 73 ),
74 74 array(
75 - 'title' => 'Add New Post',
75 + 'title' => __( 'Add New Post', 'desktop-mode' ),
76 76 'url' => admin_url( 'post-new.php' ),
77 77 'icon' => 'dashicons-plus',
78 - 'description' => 'Create a new blog post.',
78 + 'description' => __( 'Create a new blog post.', 'desktop-mode' ),
79 79 ),
80 80 array(
81 - 'title' => 'Categories',
81 + 'title' => __( 'Categories', 'desktop-mode' ),
82 82 'url' => admin_url( 'edit-tags.php?taxonomy=category' ),
83 83 'icon' => 'dashicons-category',
84 - 'description' => 'Manage post categories — add, rename, merge.',
84 + 'description' => __( 'Manage post categories: add, rename, merge.', 'desktop-mode' ),
85 85 ),
86 86 array(
87 - 'title' => 'Tags',
87 + 'title' => __( 'Tags', 'desktop-mode' ),
88 88 'url' => admin_url( 'edit-tags.php?taxonomy=post_tag' ),
89 89 'icon' => 'dashicons-tag',
90 - 'description' => 'Manage post tags.',
90 + 'description' => __( 'Manage post tags.', 'desktop-mode' ),
91 91 ),
92 92 array(
93 - 'title' => 'All Pages',
93 + 'title' => __( 'All Pages', 'desktop-mode' ),
94 94 'url' => admin_url( 'edit.php?post_type=page' ),
95 95 'icon' => 'dashicons-admin-page',
96 - 'description' => 'List and edit static pages (About, Contact, etc.).',
96 + 'description' => __( 'List and edit static pages (About, Contact, etc.).', 'desktop-mode' ),
97 97 ),
98 98 array(
99 - 'title' => 'Add New Page',
99 + 'title' => __( 'Add New Page', 'desktop-mode' ),
100 100 'url' => admin_url( 'post-new.php?post_type=page' ),
101 101 'icon' => 'dashicons-plus',
102 - 'description' => 'Create a new page.',
102 + 'description' => __( 'Create a new page.', 'desktop-mode' ),
103 103 ),
104 104 array(
105 - 'title' => 'Media Library',
105 + 'title' => __( 'Media Library', 'desktop-mode' ),
106 106 'url' => admin_url( 'upload.php' ),
107 107 'icon' => 'dashicons-admin-media',
108 - 'description' => 'Browse, upload, and manage images, files, videos.',
108 + 'description' => __( 'Browse, upload, and manage images, files, videos.', 'desktop-mode' ),
109 109 ),
110 110 array(
111 - 'title' => 'Comments',
111 + 'title' => __( 'Comments', 'desktop-mode' ),
112 112 'url' => admin_url( 'edit-comments.php' ),
113 113 'icon' => 'dashicons-admin-comments',
114 - 'description' => 'Moderate and reply to comments on posts and pages.',
114 + 'description' => __( 'Moderate and reply to comments on posts and pages.', 'desktop-mode' ),
115 115 ),
116 116 array(
117 - 'title' => 'Themes',
117 + 'title' => __( 'Themes', 'desktop-mode' ),
118 118 'url' => admin_url( 'themes.php' ),
119 119 'icon' => 'dashicons-admin-appearance',
120 - 'description' => 'Change, install, or customize the active theme.',
120 + 'description' => __( 'Change, install, or customize the active theme.', 'desktop-mode' ),
121 121 ),
122 122 array(
123 - 'title' => 'Customize',
123 + 'title' => __( 'Customize', 'desktop-mode' ),
124 124 'url' => admin_url( 'customize.php' ),
125 125 'icon' => 'dashicons-admin-customizer',
126 - 'description' => 'Live-preview theme customisation — colors, fonts, layout.',
126 + 'description' => __( 'Live-preview theme customisation: colors, fonts, layout.', 'desktop-mode' ),
127 127 ),
128 128 array(
129 - 'title' => 'Widgets',
129 + 'title' => __( 'Widgets', 'desktop-mode' ),
130 130 'url' => admin_url( 'widgets.php' ),
131 131 'icon' => 'dashicons-screenoptions',
132 - 'description' => 'Manage sidebar and footer widgets.',
132 + 'description' => __( 'Manage sidebar and footer widgets.', 'desktop-mode' ),
133 133 ),
134 134 array(
135 - 'title' => 'Menus',
135 + 'title' => __( 'Menus', 'desktop-mode' ),
136 136 'url' => admin_url( 'nav-menus.php' ),
137 137 'icon' => 'dashicons-menu',
138 - 'description' => 'Create and edit navigation menus.',
138 + 'description' => __( 'Create and edit navigation menus.', 'desktop-mode' ),
139 139 ),
140 140 array(
141 - 'title' => 'Plugins',
141 + 'title' => __( 'Plugins', 'desktop-mode' ),
142 142 'url' => admin_url( 'plugins.php' ),
143 143 'icon' => 'dashicons-admin-plugins',
144 - 'description' => 'Activate, deactivate, update or delete plugins.',
144 + 'description' => __( 'Activate, deactivate, update or delete plugins.', 'desktop-mode' ),
145 145 ),
146 146 array(
147 - 'title' => 'Add New Plugin',
147 + 'title' => __( 'Add New Plugin', 'desktop-mode' ),
148 148 'url' => admin_url( 'plugin-install.php' ),
149 149 'icon' => 'dashicons-plus',
150 - 'description' => 'Search and install new plugins from the directory.',
150 + 'description' => __( 'Search and install new plugins from the directory.', 'desktop-mode' ),
151 151 ),
152 152 array(
153 - 'title' => 'Users',
153 + 'title' => __( 'Users', 'desktop-mode' ),
154 154 'url' => admin_url( 'users.php' ),
155 155 'icon' => 'dashicons-admin-users',
156 - 'description' => 'Manage user accounts and roles.',
156 + 'description' => __( 'Manage user accounts and roles.', 'desktop-mode' ),
157 157 ),
158 158 array(
159 - 'title' => 'Add New User',
159 + 'title' => __( 'Add New User', 'desktop-mode' ),
160 160 'url' => admin_url( 'user-new.php' ),
161 161 'icon' => 'dashicons-plus',
162 - 'description' => 'Create a new user account.',
162 + 'description' => __( 'Create a new user account.', 'desktop-mode' ),
163 163 ),
164 164 array(
165 - 'title' => 'Your Profile',
165 + 'title' => __( 'Your Profile', 'desktop-mode' ),
166 166 'url' => admin_url( 'profile.php' ),
167 167 'icon' => 'dashicons-id',
168 - 'description' => 'Edit your own profile, password, admin colour scheme.',
168 + 'description' => __( 'Edit your own profile, password, admin colour scheme.', 'desktop-mode' ),
169 169 ),
170 170 array(
171 - 'title' => 'General Settings',
171 + 'title' => __( 'General Settings', 'desktop-mode' ),
172 172 'url' => admin_url( 'options-general.php' ),
173 173 'icon' => 'dashicons-admin-settings',
174 - 'description' => 'Site title, tagline, URL, timezone, language.',
174 + 'description' => __( 'Site title, tagline, URL, timezone, language.', 'desktop-mode' ),
175 175 ),
176 176 array(
177 - 'title' => 'Writing Settings',
177 + 'title' => __( 'Writing Settings', 'desktop-mode' ),
178 178 'url' => admin_url( 'options-writing.php' ),
179 179 'icon' => 'dashicons-edit',
180 - 'description' => 'Default post category, post format, remote publishing.',
180 + 'description' => __( 'Default post category, post format, remote publishing.', 'desktop-mode' ),
181 181 ),
182 182 array(
183 - 'title' => 'Reading Settings',
183 + 'title' => __( 'Reading Settings', 'desktop-mode' ),
184 184 'url' => admin_url( 'options-reading.php' ),
185 185 'icon' => 'dashicons-book',
186 - 'description' => 'Homepage, blog posts per page, search-engine visibility.',
186 + 'description' => __( 'Homepage, blog posts per page, search-engine visibility.', 'desktop-mode' ),
187 187 ),
188 188 array(
189 - 'title' => 'Discussion Settings',
189 + 'title' => __( 'Discussion Settings', 'desktop-mode' ),
190 190 'url' => admin_url( 'options-discussion.php' ),
191 191 'icon' => 'dashicons-format-chat',
192 - 'description' => 'Comment moderation, avatars, email notifications.',
192 + 'description' => __( 'Comment moderation, avatars, email notifications.', 'desktop-mode' ),
193 193 ),
194 194 array(
195 - 'title' => 'Media Settings',
195 + 'title' => __( 'Media Settings', 'desktop-mode' ),
196 196 'url' => admin_url( 'options-media.php' ),
197 197 'icon' => 'dashicons-format-image',
198 - 'description' => 'Image size settings for thumbnail / medium / large.',
198 + 'description' => __( 'Image size settings for thumbnail / medium / large.', 'desktop-mode' ),
199 199 ),
200 200 array(
201 - 'title' => 'Permalinks',
201 + 'title' => __( 'Permalinks', 'desktop-mode' ),
202 202 'url' => admin_url( 'options-permalink.php' ),
203 203 'icon' => 'dashicons-admin-links',
204 - 'description' => 'URL structure for posts, pages, categories, tags.',
204 + 'description' => __( 'URL structure for posts, pages, categories, tags.', 'desktop-mode' ),
205 205 ),
206 206 array(
207 - 'title' => 'Privacy',
207 + 'title' => __( 'Privacy', 'desktop-mode' ),
208 208 'url' => admin_url( 'options-privacy.php' ),
209 209 'icon' => 'dashicons-privacy',
210 - 'description' => 'Privacy policy page selection and preview.',
210 + 'description' => __( 'Privacy policy page selection and preview.', 'desktop-mode' ),
211 211 ),
212 212 array(
213 - 'title' => 'Tools',
213 + 'title' => __( 'Tools', 'desktop-mode' ),
214 214 'url' => admin_url( 'tools.php' ),
215 215 'icon' => 'dashicons-admin-tools',
216 - 'description' => 'Built-in site tools.',
216 + 'description' => __( 'Built-in site tools.', 'desktop-mode' ),
217 217 ),
218 218 array(
219 - 'title' => 'Import',
219 + 'title' => __( 'Import', 'desktop-mode' ),
220 220 'url' => admin_url( 'import.php' ),
221 221 'icon' => 'dashicons-download',
222 - 'description' => 'Import content from other platforms (WP, Tumblr, RSS, etc.).',
222 + 'description' => __( 'Import content from other platforms (WP, Tumblr, RSS, etc.).', 'desktop-mode' ),
223 223 ),
224 224 array(
225 - 'title' => 'Export',
225 + 'title' => __( 'Export', 'desktop-mode' ),
226 226 'url' => admin_url( 'export.php' ),
227 227 'icon' => 'dashicons-upload',
228 - 'description' => 'Export all site content as XML.',
228 + 'description' => __( 'Export all site content as XML.', 'desktop-mode' ),
229 229 ),
230 230 array(
231 - 'title' => 'Site Health',
231 + 'title' => __( 'Site Health', 'desktop-mode' ),
232 232 'url' => admin_url( 'site-health.php' ),
233 233 'icon' => 'dashicons-heart',
234 - 'description' => 'Performance and security recommendations for the site.',
234 + 'description' => __( 'Performance and security recommendations for the site.', 'desktop-mode' ),
235 235 ),
236 236 array(
237 - 'title' => 'Updates',
237 + 'title' => __( 'Updates', 'desktop-mode' ),
238 238 'url' => admin_url( 'update-core.php' ),
239 239 'icon' => 'dashicons-update',
240 - 'description' => 'WordPress, theme, and plugin updates.',
240 + 'description' => __( 'WordPress, theme, and plugin updates.', 'desktop-mode' ),
241 241 ),
242 242 );
243 243
244 244 /**
@@ -381,10 +381,16 @@
381 381 * Keyword-searches published posts or pages with WordPress's native search
382 382 * (`WP_Query` `s=`), returning data rich enough for the agent to compare
383 383 * AND for the UI to render links.
384 384 *
385 - * No AI analysis is required — every published post/page is searchable.
385 + * No AI analysis is required — every published, non-password-protected post/page is searchable.
386 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 + *
387 393 * @param string $post_type 'post' | 'page'.
388 394 * @param string $query Keyword search terms (may be empty to list newest).
389 395 * @param int $offset
390 396 * @return array
@@ -393,8 +399,9 @@
393 399 $wp_query = new WP_Query(
394 400 array(
395 401 'post_type' => $post_type,
396 402 'post_status' => 'publish',
403 + 'has_password' => false,
397 404 's' => (string) $query,
398 405 'posts_per_page' => OPENSTATION_AI_SEARCH_BATCH_SIZE,
399 406 'offset' => $offset,
400 407 'no_found_rows' => false,
@@ -446,8 +453,82 @@
446 453 return (string) mb_substr( $text, 0, 300 );
447 454 }
448 455
449 456 /**
457 + * Whether the current user may read a post the comment tools are about to
458 + * surface.
459 + *
460 + * A comment being `approved` is a moderation decision — it says nothing about
461 + * who may see the discussion. An approved comment can hang on a private,
462 + * draft, or password-protected post the caller cannot reach, so the comment
463 + * search tools must gate on the PARENT POST's visibility before returning the
464 + * comment text or the parent title. Mirrors Core's
465 + * `WP_REST_Comments_Controller::check_read_post_permission()`:
466 + *
467 + * - a password-protected parent needs the password satisfied or `edit_post`.
468 + * `post_password_required()` honours the `wp-postpass` cookie Core's
469 + * password form sets, and that is deliberate Core parity, not a gap: the
470 + * cookie only exists because the caller already entered the correct
471 + * password, and Core's comments controller reads the same cookie. The
472 + * ability itself has no password input, so a caller who never unlocked
473 + * the post front-end is refused;
474 + * - a publicly viewable parent (public status AND viewable post type) is
475 + * readable by anyone the ability admits;
476 + * - a parent whose post TYPE is not viewable (an internal/admin-only CPT)
477 + * needs `edit_post` — `read_post` cannot stand in, because a public status
478 + * resolves it to plain `read` whatever the type's visibility, which is how
479 + * Core's REST layer needs its own post-type gate too;
480 + * - any other parent (private, draft, pending, …) needs `read_post`.
481 + *
482 + * @param int|WP_Post $post Post ID or object.
483 + * @return bool
484 + */
485 +function openstation_ai_can_read_post( $post ) {
486 + // An id of 0 must stay unreadable: get_post( 0 ) falls back to the global
487 + // $post, which would judge an orphaned comment against an unrelated post.
488 + if ( is_numeric( $post ) && (int) $post <= 0 ) {
489 + return false;
490 + }
491 +
492 + $post = get_post( $post );
493 + if ( ! $post instanceof WP_Post ) {
494 + return false;
495 + }
496 +
497 + if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
498 + return false;
499 + }
500 +
501 + if ( is_post_publicly_viewable( $post ) ) {
502 + return true;
503 + }
504 +
505 + $post_type = get_post_type_object( $post->post_type );
506 + if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
507 + return current_user_can( 'edit_post', $post->ID );
508 + }
509 +
510 + return current_user_can( 'read_post', $post->ID );
511 +}
512 +
513 +/**
514 + * Whether the current user may read the post a comment is attached to.
515 + *
516 + * Used to drop comments on posts the caller cannot see from the comment
517 + * search results. See {@see openstation_ai_can_read_post()}.
518 + *
519 + * @param int|WP_Comment $comment Comment ID or object.
520 + * @return bool
521 + */
522 +function openstation_ai_can_read_comment_parent( $comment ) {
523 + $comment = get_comment( $comment );
524 + if ( ! $comment instanceof WP_Comment ) {
525 + return false;
526 + }
527 + return openstation_ai_can_read_post( (int) $comment->comment_post_ID );
528 +}
529 +
530 +/**
450 531 * Keyword-searches approved comments across all posts with WordPress's
451 532 * native comment search (`get_comments` `search=`).
452 533 *
453 534 * No AI analysis is required — every approved comment is searchable.
@@ -489,12 +570,28 @@
489 570 if ( $parent_ids ) {
490 571 _prime_post_caches( $parent_ids, false, false );
491 572 }
492 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 +
493 589 $items = array();
494 590 foreach ( $comments as $comment ) {
591 + // Readable, per the filter above.
495 592 $parent_post = get_post( $comment->comment_post_ID );
496 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
593 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
497 594
498 595 $items[] = array(
499 596 'id' => (int) $comment->comment_ID,
500 597 'type' => 'comment',
@@ -504,9 +601,9 @@
504 601 // Links.
505 602 'url' => (string) get_comment_link( $comment ),
506 603 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ),
507 604 'post_id' => (int) $comment->comment_post_ID,
508 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
605 + 'post_url' => (string) get_permalink( $parent_post ),
509 606 );
510 607 }
511 608
512 609 return array(
@@ -553,8 +650,25 @@
553 650 'error' => 'post_id must be a positive integer.',
554 651 );
555 652 }
556 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 +
557 671 $base_args = array(
558 672 'post_id' => $post_id,
559 673 'status' => 'approve',
560 674 'type' => 'comment',
@@ -573,10 +687,11 @@
573 687 );
574 688
575 689 $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) );
576 690
691 + // Readable, per the gate above.
577 692 $parent_post = get_post( $post_id );
578 - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '';
693 + $parent_title = wp_strip_all_tags( $parent_post->post_title );
579 694
580 695 $items = array();
581 696 foreach ( $comments as $comment ) {
582 697 $items[] = array(
@@ -616,8 +731,22 @@
616 731 * required. Comments opportunistically surface the `spam` / `harmful`
617 732 * verdict when the comment-moderation analysis happens to have run, but
618 733 * its absence never blocks the entity from being returned.
619 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 + *
620 749 * @param string $entity_type 'post' | 'page' | 'comment'.
621 750 * @param int $entity_id
622 751 * @return array|null
623 752 */
@@ -625,11 +754,22 @@
625 754 $entity_id = (int) $entity_id;
626 755
627 756 if ( in_array( $entity_type, array( 'post', 'page' ), true ) ) {
628 757 $post = get_post( $entity_id );
629 - 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 ) ) {
630 765 return null;
631 766 }
767 +
768 + if ( ! openstation_ai_can_read_post( $post ) ) {
769 + return null;
770 + }
771 +
632 772 return array(
633 773 'id' => $entity_id,
634 774 'type' => $post->post_type,
635 775 'title' => wp_strip_all_tags( $post->post_title ),
@@ -642,25 +782,49 @@
642 782 }
643 783
644 784 if ( 'comment' === $entity_type ) {
645 785 $comment = get_comment( $entity_id );
646 - 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 ) ) {
647 792 return null;
648 793 }
649 - $meta = openstation_ai_get_meta( 'comment', $entity_id );
650 - $parent_post = get_post( $comment->comment_post_ID );
651 - return array(
794 +
795 + // Reading an unapproved comment is an editor's business, per Core's
796 + // WP_REST_Comments_Controller::check_read_permission().
797 + if ( '1' !== (string) $comment->comment_approved && ! current_user_can( 'edit_comment', $entity_id ) ) {
798 + return null;
799 + }
800 +
801 + // The AI verdicts are the moderation queue's data, so they follow the
802 + // moderation capability rather than the per-comment edit one.
803 + $can_moderate = current_user_can( 'moderate_comments' );
804 + $parent_post = get_post( (int) $comment->comment_post_ID );
805 +
806 + $meta = $can_moderate ? openstation_ai_get_meta( 'comment', $entity_id ) : null;
807 + $entity = array(
652 808 'id' => $entity_id,
653 809 'type' => 'comment',
654 810 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ),
655 811 'post_id' => (int) $comment->comment_post_ID,
656 - 'post_title' => $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '',
657 - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '',
812 + 'post_title' => wp_strip_all_tags( $parent_post->post_title ),
813 + 'post_url' => (string) get_permalink( $parent_post ),
658 814 'url' => (string) get_comment_link( $comment ),
659 - 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . $entity_id ),
660 - 'harmful' => $meta ? (bool) ( $meta['harmful'] ?? false ) : false,
661 - 'spam' => $meta ? (bool) ( $meta['spam'] ?? false ) : false,
815 + 'edit_url' => current_user_can( 'edit_comment', $entity_id )
816 + ? admin_url( 'comment.php?action=editcomment&c=' . $entity_id )
817 + : '',
662 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;
663 827 }
664 828
665 829 return null;
666 830 }
@@ -669,41 +833,37 @@
669 833 // Agentic search loop
670 834 // ---------------------------------------------------------------------------
671 835
672 836 /**
673 - * Returns a friendly progress message for a tool name — surfaced to the
674 - * client via SSE so the user sees "Looking through your posts…" rather
675 - * than the raw tool call.
837 + * Returns the label for the "keep looking" button on an exhausted search.
676 838 *
677 - * @param string $tool_name
839 + * One full sentence per resumable tool: a noun interpolated into a shared
840 + * template cannot be translated.
841 + *
842 + * @param string $resume_tool Tool the client would resume from.
843 + * @param int $from_item 1-based index of the next item to search.
678 844 * @return string
679 845 */
680 -function openstation_ai_progress_message( $tool_name ) {
681 - switch ( $tool_name ) {
682 - case 'search_posts':
683 - return 'Looking through your posts…';
846 +function openstation_ai_continue_label( $resume_tool, $from_item ) {
847 + switch ( $resume_tool ) {
684 848 case 'search_pages':
685 - return 'Checking your 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 );
686 851 case 'search_comments':
687 - return 'Reading through comments…';
688 - case 'search_comments_by_post':
689 - return 'Scanning comments on that post…';
690 - case 'list_admin_pages':
691 - return 'Finding the right admin page…';
692 - case 'search_wporg_plugins':
693 - return 'Searching the WordPress.org plugin directory…';
694 - case 'get_php_error_log':
695 - return 'Tailing the PHP error log…';
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 );
696 857 }
697 - return 'Thinking…';
698 858 }
699 859
700 860 /**
701 861 * Returns the tools a client may resume an exhausted search from.
702 862 *
703 - * Single source of truth for every `resume_tool` allowlist — the REST
704 - * arg sanitizer, the SSE handler, and the `$initial_tool` validation
705 - * inside `openstation_ai_run_search()`. `search_comments_by_post` is
863 + * Single source of truth for every `resume_tool` allowlist: the REST
864 + * arg sanitizer and the `$initial_tool` validation inside
865 + * `openstation_ai_run_search()`. `search_comments_by_post` is
706 866 * deliberately absent: the `continue` payload carries no `post_id`, so
707 867 * it cannot truly resume — exhausted runs map it to `search_comments`
708 868 * when building the `continue` object.
709 869 *
@@ -851,26 +1011,15 @@
851 1011 * For continuation runs ($initial_tool + $start_offset > 0), the system
852 1012 * message primes the agent to resume from the last searched position with
853 1013 * the same keywords.
854 1014 *
855 - * @param string $query User's natural-language search.
856 - * @param string|null $initial_tool Tool name to resume from, or null for fresh search.
857 - * @param int $start_offset Offset to resume from (0 for fresh).
858 - * @param callable|null $on_progress Optional progress emitter for SSE ticks.
859 - * @param array $extra Extensibility context (command tools, prompt overrides, …).
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, …).
860 1019 * @return array|WP_Error
861 1020 */
862 -function openstation_ai_run_search( $query, $initial_tool = null, $start_offset = 0, $on_progress = null, array $extra = array() ) {
863 - /**
864 - * Progress emitter — sends a tick to the caller if they provided a
865 - * callable; no-op otherwise. Callers use this to render real-time
866 - * status to the user via SSE.
867 - */
868 - $emit = static function ( array $event ) use ( $on_progress ) {
869 - if ( is_callable( $on_progress ) ) {
870 - $on_progress( $event );
871 - }
872 - };
1021 +function openstation_ai_run_search( $query, $initial_tool = null, $start_offset = 0, array $extra = array() ) {
873 1022 $start_offset = max( 0, (int) $start_offset );
874 1023 $search_tools = array( 'search_posts', 'search_pages', 'search_comments', 'search_comments_by_post' );
875 1024 $valid_tools = array_merge(
876 1025 $search_tools,
@@ -1151,15 +1300,8 @@
1151 1300 }
1152 1301
1153 1302 $answer_schema = openstation_ai_search_answer_schema();
1154 1303
1155 - $emit(
1156 - array(
1157 - 'phase' => 'start',
1158 - 'message' => 'Thinking about your question…',
1159 - )
1160 - );
1161 -
1162 1304 // -----------------------------------------------------------------------
1163 1305 // First call — user query as the sole message, instructions as system
1164 1306 // guidance. Generation routes through the WordPress AI Client; the tools
1165 1307 // are advertised as function declarations and dispatched by this loop.
@@ -1166,10 +1308,15 @@
1166 1308 // The full ordered conversation is rebuilt and re-sent each turn.
1167 1309 // -----------------------------------------------------------------------
1168 1310 $messages = array( openstation_ai_user_text_message( $query ) );
1169 1311
1170 - $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
1312 + $generation_context = array(
1313 + 'source' => 'ai-copilot/search',
1314 + 'request_id' => $request_id,
1315 + );
1171 1316
1317 + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1318 +
1172 1319 if ( is_wp_error( $turn ) ) {
1173 1320 return $turn;
1174 1321 }
1175 1322
@@ -1210,14 +1357,8 @@
1210 1357 $function_calls = is_array( $turn['function_calls'] ?? null ) ? $turn['function_calls'] : array();
1211 1358
1212 1359 // No tool calls in this response → final answer.
1213 1360 if ( empty( $function_calls ) ) {
1214 - $emit(
1215 - array(
1216 - 'phase' => 'composing',
1217 - 'message' => 'Putting together your answer…',
1218 - )
1219 - );
1220 1361 // A toolless turn with no extractable text never reaches here:
1221 1362 // openstation_ai_client_generate() returns
1222 1363 // `openstation_ai_empty_answer` for that case, handled with the
1223 1364 // other generation errors above.
@@ -1224,9 +1365,9 @@
1224 1365 $text = (string) ( $turn['text'] ?? '' );
1225 1366
1226 1367 $answer = json_decode( $text, true );
1227 1368 if ( ! is_array( $answer ) ) {
1228 - return new WP_Error( 'openstation_ai_result_parse', 'Could not parse structured search answer.' );
1369 + return new WP_Error( 'openstation_ai_result_parse', __( 'Could not parse structured search answer.', 'desktop-mode' ) );
1229 1370 }
1230 1371
1231 1372 $answer_type = isset( $answer['answer_type'] ) && in_array( $answer['answer_type'], array( 'entity', 'navigation', 'chat' ), true )
1232 1373 ? (string) $answer['answer_type']
@@ -1380,16 +1521,8 @@
1380 1521 $raw = json_decode( $fc['arguments'] ?? '{}', true );
1381 1522 $args = is_array( $raw ) ? $raw : array();
1382 1523 $offset = max( 0, (int) ( $args['offset'] ?? 0 ) );
1383 1524
1384 - $emit(
1385 - array(
1386 - 'phase' => 'tool_call',
1387 - 'tool' => $tool_name,
1388 - 'message' => openstation_ai_progress_message( $tool_name ),
1389 - )
1390 - );
1391 -
1392 1525 do_action(
1393 1526 'openstation_ai_tool_called',
1394 1527 array(
1395 1528 'tool_name' => $tool_name,
@@ -1472,9 +1605,9 @@
1472 1605 // results to the conversation, then regenerate with the full history.
1473 1606 $messages[] = $turn['message'];
1474 1607 $messages[] = openstation_ai_tool_result_message( $tool_outputs );
1475 1608
1476 - $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions );
1609 + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context );
1477 1610
1478 1611 if ( is_wp_error( $turn ) ) {
1479 1612 return $turn;
1480 1613 }
@@ -1490,20 +1623,19 @@
1490 1623 // `search_comments_by_post` cannot resume — the continue payload
1491 1624 // carries no post_id — so fall back to plain comment search,
1492 1625 // keeping `tool` inside openstation_ai_search_resumable_tools().
1493 1626 $resume_tool = 'search_comments_by_post' === $last_tool ? 'search_comments' : $last_tool;
1494 - $type_label = str_replace( 'search_', '', $resume_tool ) . 's';
1495 1627 $continue = array(
1496 1628 'tool' => $resume_tool,
1497 1629 'entity_type' => rtrim( str_replace( 'search_', '', $resume_tool ), 's' ),
1498 1630 'offset' => $next_offset,
1499 - 'label' => sprintf( 'Continue searching in %s (from item %d)', $type_label, $next_offset + 1 ),
1631 + 'label' => openstation_ai_continue_label( $resume_tool, $next_offset + 1 ),
1500 1632 );
1501 1633 }
1502 1634
1503 1635 $final = array(
1504 1636 'answer_type' => 'chat',
1505 - 'message' => 'I searched 100 items without finding a clear match. Want me to keep looking further?',
1637 + 'message' => __( 'I searched 100 items without finding a clear match. Want me to keep looking further?', 'desktop-mode' ),
1506 1638 'entity' => null,
1507 1639 'admin_links' => null,
1508 1640 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS,
1509 1641 'exhausted' => ! $last_has_more,
@@ -1760,9 +1892,13 @@
1760 1892 $user_id,
1761 1893 array( openstation_ai_user_text_message( $user_message ) ),
1762 1894 array(), // no tools — we want a plain reply
1763 1895 null, // no JSON schema — free-form text
1764 - $instructions
1896 + $instructions,
1897 + array(
1898 + 'source' => 'ai-copilot/followup',
1899 + 'request_id' => $request_id,
1900 + )
1765 1901 );
1766 1902
1767 1903 // `openstation_ai_empty_answer` is the one generation error this path
1768 1904 // deliberately absorbs: the command DID run, so a text-less summary turn
@@ -1945,9 +2081,9 @@
1945 2081 function openstation_rest_ai_search_permission() {
1946 2082 if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
1947 2083 return new WP_Error(
1948 2084 'openstation_ai_forbidden',
1949 - 'You must be logged in to use the AI assistant.',
2085 + __( 'You must be logged in to use the AI assistant.', 'desktop-mode' ),
1950 2086 array( 'status' => 403 )
1951 2087 );
1952 2088 }
1953 2089 if ( ! openstation_ai_is_available() ) {
@@ -1952,17 +2088,26 @@
1952 2088 }
1953 2089 if ( ! openstation_ai_is_available() ) {
1954 2090 return new WP_Error(
1955 2091 'openstation_ai_unavailable',
1956 - 'The AI assistant is unavailable on this site.',
2092 + __( 'The AI assistant is unavailable on this site.', 'desktop-mode' ),
1957 2093 array( 'status' => 503 )
1958 2094 );
1959 2095 }
1960 2096 if ( ! openstation_ai_is_enabled( get_current_user_id() ) ) {
2097 + // The message names the tab for consumers that only get text (a REST
2098 + // client, `wp.os.ai.ask()`). `settings_tab` names it again as data,
2099 + // so the overlay can offer a one-click link without parsing prose.
1961 2100 return new WP_Error(
1962 2101 'openstation_ai_disabled',
1963 - 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.',
1964 - array( 'status' => 403 )
2102 + __(
2103 + 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.',
2104 + 'desktop-mode'
2105 + ),
2106 + array(
2107 + 'status' => 403,
2108 + 'settings_tab' => 'features',
2109 + )
1965 2110 );
1966 2111 }
1967 2112 return true;
1968 2113 }
@@ -1973,8 +2118,14 @@
1973 2118 * @param WP_REST_Request $request
1974 2119 * @return WP_REST_Response|WP_Error
1975 2120 */
1976 2121 function openstation_rest_ai_search( WP_REST_Request $request ) {
2122 + // The agent loop runs up to OPENSTATION_AI_SEARCH_MAX_ITERATIONS model
2123 + // round-trips, each with a tool call, which overruns a default 30s
2124 + // max_execution_time. The streaming endpoint used to raise this; it is
2125 + // the only path now, so it carries the limit.
2126 + @set_time_limit( 120 ); // phpcs:ignore
2127 +
1977 2128 $user_id = get_current_user_id();
1978 2129 $query = $request->get_param( 'query' );
1979 2130 $resume_tool = $request->get_param( 'resume_tool' );
1980 2131 $start_offset = $request->get_param( 'start_offset' );
@@ -2018,9 +2169,9 @@
2018 2169 ? ( is_array( $follow_up['result'] ) ? $follow_up['result'] : array( 'value' => $follow_up['result'] ) )
2019 2170 : array();
2020 2171 $result = openstation_ai_run_followup( $query, $tool, $outcome, $extra );
2021 2172 } else {
2022 - $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, null, $extra );
2173 + $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, $extra );
2023 2174 }
2024 2175
2025 2176 if ( is_wp_error( $result ) ) {
2026 2177 $request_id = isset( $extra['request_id'] ) ? (string) $extra['request_id'] : '';
@@ -2320,120 +2471,4 @@
2320 2471 }
2321 2472
2322 2473 return array_slice( $all, -1 * ( $lines + 1 ) );
2323 2474 }
2324 -
2325 -// ---------------------------------------------------------------------------
2326 -// Streaming endpoint (Server-Sent Events)
2327 -//
2328 -// EventSource can't send POST or custom headers, so we ride admin-ajax.php
2329 -// which handles cookie-based auth natively. The nonce goes in the URL.
2330 -// Output buffering is forcibly disabled and every emit is flushed so the
2331 -// browser receives progress ticks in real time.
2332 -// ---------------------------------------------------------------------------
2333 -
2334 -/**
2335 - * Admin-ajax handler for the streaming search endpoint.
2336 - *
2337 - * URL: /wp-admin/admin-ajax.php?action=openstation_ai_search_stream
2338 - * &nonce=<rest_nonce>
2339 - * &query=<user question>
2340 - * &resume_tool=<search_posts|…> (optional)
2341 - * &start_offset=<int> (optional)
2342 - *
2343 - * Emits SSE events:
2344 - * data: { "event": "progress", "phase": "tool_call", "message": "…" }
2345 - * data: { "event": "done", "result": { … } }
2346 - * data: { "event": "error", "message": "…" }
2347 - */
2348 -function openstation_ai_ajax_search_stream() {
2349 - $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
2350 - if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
2351 - status_header( 403 );
2352 - exit;
2353 - }
2354 - if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) {
2355 - status_header( 403 );
2356 - exit;
2357 - }
2358 - $user_id = get_current_user_id();
2359 - if ( ! openstation_ai_is_available() ) {
2360 - status_header( 503 );
2361 - exit;
2362 - }
2363 - if ( ! openstation_ai_is_enabled( $user_id ) ) {
2364 - status_header( 403 );
2365 - exit;
2366 - }
2367 -
2368 - $query = isset( $_GET['query'] ) ? sanitize_text_field( wp_unslash( $_GET['query'] ) ) : ''; // phpcs:ignore WordPress.Security
2369 - if ( trim( $query ) === '' ) {
2370 - status_header( 400 );
2371 - exit;
2372 - }
2373 -
2374 - $resume_tool = isset( $_GET['resume_tool'] ) ? sanitize_key( wp_unslash( $_GET['resume_tool'] ) ) : null; // phpcs:ignore WordPress.Security
2375 - $start_offset = isset( $_GET['start_offset'] ) ? absint( $_GET['start_offset'] ) : 0; // phpcs:ignore WordPress.Security
2376 - if ( null !== $resume_tool && ! in_array( $resume_tool, openstation_ai_search_resumable_tools(), true ) ) {
2377 - $resume_tool = null;
2378 - }
2379 -
2380 - // SSE headers — tell nginx to stop buffering, tell the browser this is
2381 - // a persistent event stream.
2382 - header( 'Content-Type: text/event-stream; charset=utf-8' );
2383 - header( 'Cache-Control: no-cache, no-store, must-revalidate' );
2384 - header( 'X-Accel-Buffering: no' );
2385 - header( 'Connection: keep-alive' );
2386 -
2387 - // Let other requests from this user proceed (release session lock).
2388 - if ( session_status() === PHP_SESSION_ACTIVE ) {
2389 - session_write_close();
2390 - }
2391 -
2392 - // Kill any output buffers PHP set up, otherwise nothing flushes until
2393 - // the request ends — which defeats the whole point of streaming.
2394 - while ( ob_get_level() > 0 ) {
2395 - @ob_end_flush(); // phpcs:ignore
2396 - }
2397 - @ini_set( 'output_buffering', 'off' ); // phpcs:ignore
2398 - @ini_set( 'zlib.output_compression', 'off' ); // phpcs:ignore
2399 - @set_time_limit( 120 ); // phpcs:ignore
2400 -
2401 - $emit = static function ( array $payload ) {
2402 - echo 'data: ' . wp_json_encode( $payload ) . "\n\n";
2403 - @ob_flush(); // phpcs:ignore
2404 - flush();
2405 - };
2406 -
2407 - // Initial tick so the EventSource opens immediately and the JS can
2408 - // start showing "Thinking…" without waiting for the first model call.
2409 - $emit( array( 'event' => 'open' ) );
2410 -
2411 - $result = openstation_ai_run_search(
2412 - $query,
2413 - $resume_tool,
2414 - $start_offset,
2415 - function ( $progress ) use ( $emit ) {
2416 - $emit( array_merge( array( 'event' => 'progress' ), $progress ) );
2417 - }
2418 - );
2419 -
2420 - if ( is_wp_error( $result ) ) {
2421 - $emit(
2422 - array(
2423 - 'event' => 'error',
2424 - 'message' => $result->get_error_message(),
2425 - 'code' => $result->get_error_code(),
2426 - )
2427 - );
2428 - } else {
2429 - $emit(
2430 - array(
2431 - 'event' => 'done',
2432 - 'result' => $result,
2433 - )
2434 - );
2435 - }
2436 -
2437 - exit;
2438 -}
2439 -add_action( 'wp_ajax_openstation_ai_search_stream', 'openstation_ai_ajax_search_stream' );