]*>.*?#is'; /** Inline elements that belong INSIDE a paragraph. A model sometimes closes * a paragraph, drops one of these bare between blocks, and opens a new * paragraph — one sentence in three pieces. {@see heal_loose_inlines()}. */ const INLINE_TAGS = array( 'a', 'strong', 'em', 'b', 'i', 'code', 'u', 's', 'sub', 'sup', 'mark', 'small', 'abbr', 'kbd', 'br' ); /** @var Settings */ private $settings; /** * @param Settings $settings Settings store. */ public function __construct( Settings $settings ) { $this->settings = $settings; } /** * Hook the REST routes. */ public function register() { add_action( 'rest_api_init', array( $this, 'routes' ) ); } /** * Launcher state for the admin bootstrap: what the quill needs to know to be * live, dimmed-with-guidance, and honest about which prerequisite is missing. * * @return array{writesOn:bool,providerReady:bool} */ public function state() { return array( 'writesOn' => (bool) $this->settings->enabled( 'enable_agent_writes' ), 'providerReady' => Assist::ai_available(), 'imageReady' => self::image_ready(), 'canUpload' => current_user_can( 'upload_files' ), ); } /** * Whether the connected AI provider can generate images — drives the per-slot * "Generate" buttons (feature-detected, never a dead-end button; the library * picker works regardless). * * @return bool */ public static function image_ready() { if ( ! function_exists( 'wp_ai_client_prompt' ) ) { return false; } try { return (bool) wp_ai_client_prompt( 'capability probe' )->is_supported_for_image_generation(); } catch ( \Throwable $e ) { return false; } } /** * Define the routes. */ public function routes() { register_rest_route( self::NS, '/assistant/compose', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'compose' ), 'permission_callback' => array( $this, 'can_compose' ), ) ); register_rest_route( self::NS, '/assistant/outline', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'outline' ), 'permission_callback' => array( $this, 'can_compose' ), ) ); register_rest_route( self::NS, '/assistant/compose-section', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'compose_section' ), 'permission_callback' => array( $this, 'can_compose' ), ) ); register_rest_route( self::NS, '/assistant/compose-meta', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'compose_meta' ), 'permission_callback' => array( $this, 'can_compose' ), ) ); register_rest_route( self::NS, '/assistant/refine', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'refine' ), 'permission_callback' => array( $this, 'can_compose' ), ) ); register_rest_route( self::NS, '/assistant/revise-block', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'revise_block' ), 'permission_callback' => array( $this, 'can_compose' ), ) ); register_rest_route( self::NS, '/assistant/generate-image', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'generate_image' ), 'permission_callback' => array( $this, 'can_generate_image' ), ) ); register_rest_route( self::NS, '/assistant/create', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'create' ), 'permission_callback' => array( $this, 'can_create' ), ) ); register_rest_route( self::NS, '/assistant/posts', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'posts' ), 'permission_callback' => array( $this, 'can_compose' ), ) ); register_rest_route( self::NS, '/assistant/post/(?P\d+)', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'fetch_post' ), 'permission_callback' => array( $this, 'can_edit_target' ), ) ); register_rest_route( self::NS, '/assistant/update', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update' ), 'permission_callback' => array( $this, 'can_edit_target' ), ) ); } /** * Compose may run for anyone who could edit posts — the same bar as the editor's * Draft-with-AI buttons — but only while the owner's write switch is on. * * @return bool|\WP_Error */ public function can_compose() { if ( ! current_user_can( 'edit_posts' ) ) { return false; } return $this->writes_enabled(); } /** * Create additionally needs the REQUESTED type's create capability — checked here * against the raw param so a refusal is a clean 403 before any work, and checked * with the same cap wp-admin's "Add New" uses. * * @param \WP_REST_Request $request Request. * @return bool|\WP_Error */ public function can_create( \WP_REST_Request $request ) { $enabled = $this->writes_enabled(); if ( true !== $enabled ) { return $enabled; } $type = sanitize_key( (string) ( $request->get_param( 'type' ) ? $request->get_param( 'type' ) : 'post' ) ); $pto = get_post_type_object( $type ); if ( ! $pto ) { return false; } $cap = ! empty( $pto->cap->create_posts ) ? $pto->cap->create_posts : ( ! empty( $pto->cap->edit_posts ) ? $pto->cap->edit_posts : '' ); return '' !== $cap && current_user_can( $cap ); } /** * Generating an image also imports it into the media library, so it needs the * upload capability on top of the compose bar — the same rule wp-admin applies * to the media uploader itself. * * @return bool|\WP_Error */ public function can_generate_image() { $can = $this->can_compose(); if ( true !== $can ) { return $can; } return current_user_can( 'upload_files' ); } /** * Editing an EXISTING post needs edit_post on that specific post — the same * bar the editor itself applies — on top of the owner's write switch. The * target must also be an agent-visible type. * * @param \WP_REST_Request $request Request. * @return bool|\WP_Error */ public function can_edit_target( \WP_REST_Request $request ) { $enabled = $this->writes_enabled(); if ( true !== $enabled ) { return $enabled; } $post = get_post( absint( $request->get_param( 'id' ) ) ); if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, Content::post_types(), true ) ) { return false; } return current_user_can( 'edit_post', $post->ID ); } /** * The owner's write-consent gate, shared by both routes. A WP_Error (not false) * when the switch is off, so the UI can say WHICH prerequisite is missing * instead of a bare 403. * * @return true|\WP_Error */ private function writes_enabled() { if ( $this->settings->enabled( 'enable_agent_writes' ) ) { return true; } return new \WP_Error( 'agentimus_writes_off', __( 'AI writing is switched off on this site. Turn on “Let connected agents write” in Settings → Discovery → MCP server.', 'agentimus' ), array( 'status' => 403 ) ); } /** * POST /assistant/compose — one structured generation from the owner's brief. * Returns a draft DOCUMENT (title, body, excerpt, description, topics, tags, * categories) for the preview card. Writes nothing. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function compose( \WP_REST_Request $request ) { $prompt = self::read_brief( $request ); if ( is_wp_error( $prompt ) ) { return $prompt; } // An approved outline (the outline-first gate) rides along as a contract. // It came from our own /outline response, but it's re-held to the schema // anyway — the same both-directions rule the draft round-trip follows. $outline = $request->get_param( 'outline' ); if ( ! empty( $outline ) ) { $outline = self::sanitize_outline( $outline ); if ( is_wp_error( $outline ) ) { return new \WP_Error( 'agentimus_outline_invalid', __( 'The outline looks empty — give it a title and at least one section, or draft without it.', 'agentimus' ), array( 'status' => 400 ) ); } $prompt = self::outline_prompt( $prompt, $outline ); } if ( ! Assist::ai_available() ) { return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI text model is configured. Add or enable one under Settings → AI, then try again.', 'agentimus' ), array( 'status' => 503 ) ); } $text = ( new Assist( $this->settings ) )->generate( self::system_prompt(), $prompt, self::COMPOSE_TOKENS ); if ( is_wp_error( $text ) ) { return self::friendly_ai_error( $text ); } $draft = self::parse_draft( $text ); if ( is_wp_error( $draft ) ) { return $draft; } return rest_ensure_response( array( 'draft' => $draft ) ); } /** * POST /assistant/outline — the cheap step before the expensive one: a * skeleton (working title + sections) from the same brief, for the owner to * shape BEFORE the full generation runs. Rerolling a skeleton costs pennies; * rerolling a whole article doesn't — that's the entire point of the gate. * Writes nothing. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function outline( \WP_REST_Request $request ) { $prompt = self::read_brief( $request ); if ( is_wp_error( $prompt ) ) { return $prompt; } if ( ! Assist::ai_available() ) { return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI text model is configured. Add or enable one under Settings → AI, then try again.', 'agentimus' ), array( 'status' => 503 ) ); } $text = ( new Assist( $this->settings ) )->generate( self::outline_system_prompt(), $prompt, self::OUTLINE_TOKENS ); if ( is_wp_error( $text ) ) { return self::friendly_ai_error( $text ); } $outline = self::parse_outline( $text ); if ( is_wp_error( $outline ) ) { return $outline; } return rest_ensure_response( array( 'outline' => $outline ) ); } /** * POST /assistant/compose-section — ONE PART of a staged article: a numbered * outline section, the introduction, or the closing. The client is the * parallelism (PHP stays one call per request): it fires every part at once, * assembles them in outline order, and the whole-document ceiling disappears * because no single call ever writes the whole document. Consistency comes * from the SPINE every part carries — the brief plus the complete outline — * and from the site's Content Guidelines riding the choke point as always. * Writes nothing. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function compose_section( \WP_REST_Request $request ) { $prompt = self::read_brief( $request ); if ( is_wp_error( $prompt ) ) { return $prompt; } $outline = self::sanitize_outline( $request->get_param( 'outline' ) ); if ( is_wp_error( $outline ) ) { return new \WP_Error( 'agentimus_outline_invalid', __( 'The outline looks empty — give it a title and at least one section first.', 'agentimus' ), array( 'status' => 400 ) ); } $part = (string) $request->get_param( 'part' ); $index = -1; if ( 'section' === $part ) { $index = (int) $request->get_param( 'index' ); if ( $index < 0 || $index >= count( $outline['sections'] ) ) { $part = ''; // An index outside the outline is the same refusal as a bad part. } } if ( ! in_array( $part, array( 'intro', 'section', 'conclusion' ), true ) ) { return new \WP_Error( 'agentimus_part_invalid', __( 'That isn’t a part of this article.', 'agentimus' ), array( 'status' => 400 ) ); } if ( ! Assist::ai_available() ) { return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI text model is configured. Add or enable one under Settings → AI, then try again.', 'agentimus' ), array( 'status' => 503 ) ); } $text = ( new Assist( $this->settings ) )->generate( self::staged_part_system_prompt(), self::staged_part_prompt( $prompt, $outline, $part, $index ), self::STAGED_PART_TOKENS ); if ( is_wp_error( $text ) ) { return self::friendly_ai_error( $text ); } $html = self::clean_section_html( $text ); if ( is_wp_error( $html ) ) { return $html; } // The heading is OURS, not the model's: a section always opens with the // outline's h2 verbatim (image-slot anchors depend on it), and the intro // and closing never carry one — whatever the model decided. $html = 'section' === $part ? self::enforce_section_heading( $html, $outline['sections'][ $index ]['heading'] ) : self::strip_leading_heading( $html ); if ( is_wp_error( $html ) ) { return $html; } return rest_ensure_response( array( 'part' => $part, 'index' => $index, 'html' => $html, ) ); } /** * POST /assistant/compose-meta — the staged article's DRESSING in one small * parallel call: title, excerpt, AI description, topics, tags, categories * and the image slots. Document-level facts belong to the one call that * reads the whole outline — sections never guess them. Writes nothing. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function compose_meta( \WP_REST_Request $request ) { $prompt = self::read_brief( $request ); if ( is_wp_error( $prompt ) ) { return $prompt; } $outline = self::sanitize_outline( $request->get_param( 'outline' ) ); if ( is_wp_error( $outline ) ) { return new \WP_Error( 'agentimus_outline_invalid', __( 'The outline looks empty — give it a title and at least one section first.', 'agentimus' ), array( 'status' => 400 ) ); } if ( ! Assist::ai_available() ) { return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI text model is configured. Add or enable one under Settings → AI, then try again.', 'agentimus' ), array( 'status' => 503 ) ); } $text = ( new Assist( $this->settings ) )->generate( self::staged_meta_system_prompt(), self::staged_meta_prompt( $prompt, $outline ), self::STAGED_META_TOKENS ); if ( is_wp_error( $text ) ) { return self::friendly_ai_error( $text ); } $meta = self::parse_meta( $text ); if ( is_wp_error( $meta ) ) { return $meta; } return rest_ensure_response( array( 'meta' => $meta ) ); } /** * The staged-part instruction: write ONE part of an article that other calls * are writing the rest of. The continuity rules stand in for the shared * state parallel calls can't have. * * @return string */ public static function staged_part_system_prompt() { return 'You write ONE PART of a WordPress post. The post is being written part by part from an owner-approved ' . 'outline — the other parts are written by separate calls, so write ONLY the part you are asked for: ' . 'no post title, no other sections, no meta commentary. ' . 'Respond with ONLY the part\'s content as clean HTML using only

,

,

,