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' ), 'types' => self::types(), ); } /** * The agent-visible types the assistant can write, each carrying the SHAPE it * will be written in — so the chooser on the brief screen and the filter on * the picker both render from one list, and neither can offer a type the * endpoints would reject. * * @return array */ public static function types() { $out = array(); foreach ( Content::post_types() as $slug ) { // The assistant WRITES A BODY, and a type without editor support does // not keep its body in post_content — the plugin that registered it // keeps it somewhere we cannot see. Offering such a type would take a // paid-for draft and file it into a field nothing ever renders, and // report success. The rest of the plugin still lists these types // happily: reading what a type has is a different question from // writing into it, and only writing can lose someone's work. if ( ! post_type_supports( $slug, 'editor' ) ) { continue; } $out[] = array( 'slug' => $slug, 'label' => Content::label( $slug ), // Both: the plural names the chooser, the singular writes the prose // around it ("Describe the post"). One of them is always wrong for // the other job. 'singular' => Content::singular( $slug ), 'shape' => self::shape_for( $slug ), 'presets' => self::presets_for( $slug ), // What this type can actually be filed under, so the drawer never // shows a term row the write would drop ({@see self::terms_for()}). 'terms' => self::terms_for( $slug, self::shape_for( $slug ) ), ); } return self::name_types( $out ); } /** * The name a type answers to in the chooser. * * "Products" is not one word, it is two plugins. WooCommerce registers * `product` labelled "Products"; FluentCart registers `fluent-products` * labelled "Products". A shop running both offered two identical entries and * no way to tell which was about to be written into. * * The vendor name comes from {@see Content::source()}, which the Content * Types setting has always used: it walks the call stack at registration to * the plugin folder that registered the type, then reads that plugin's own * header Name. Nothing is hardcoded and no slug list exists — and reusing it * means the chooser and the setting cannot disagree about who owns a type, * which two implementations of the same idea eventually would. * * The slug is the fallback, not the answer: a type registered by a theme or * an mu-plugin has no plugin name to read, and a bare "Products" would still * be ambiguous. It is also appended when two entries STILL read the same * after their vendors are named — one plugin registering two types both * called Products is unlikely, and unlikely is not never. * * @param array $types Type rows, each with slug + label. * @return array */ private static function name_types( array $types ) { foreach ( $types as &$row ) { $source = (string) Content::source( (string) $row['slug'] ); if ( '' !== $source ) { /* translators: 1: post type label, 2: the plugin that registered it. */ $row['label'] = sprintf( __( '%1$s · %2$s', 'agentimus' ), $row['label'], $source ); } } unset( $row ); // Anything still indistinguishable gets the one identifier that cannot // collide. $seen = array(); foreach ( $types as $row ) { $seen[ $row['label'] ] = isset( $seen[ $row['label'] ] ) ? $seen[ $row['label'] ] + 1 : 1; } foreach ( $types as &$row ) { if ( $seen[ $row['label'] ] > 1 ) { /* translators: 1: post type label, 2: post type slug. */ $row['label'] = sprintf( __( '%1$s · %2$s', 'agentimus' ), $row['label'], $row['slug'] ); } } unset( $row ); return $types; } /** * Every link the assistant writes to SOMEONE ELSE'S site opens in a new tab. * * A reader who follows a citation out of an article and loses the article is * a reader who does not come back, and the piece we just wrote is the thing * they were reading. Links to this site keep the normal behaviour: staying * inside a site is not leaving it, and a new tab for every internal link is * how you end up with fifteen of them. * * `rel` gains noopener/noreferrer, which is not optional with _blank: without * it the opened page can reach back through window.opener. * * Applied to STORED content, not just the preview, so the behaviour survives * into the post rather than being a trick of how the drawer renders. * * @param string $html Post body HTML. * @return string */ public static function outbound_links_blank( $html ) { $html = (string) $html; if ( '' === trim( $html ) || false === strpos( $html, 'loadHTML( '
' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET ); libxml_clear_errors(); if ( ! $ok || ! $dom->documentElement ) { return $html; // Unparseable: leave it exactly as it was. } $touched = false; foreach ( $dom->getElementsByTagName( 'a' ) as $a ) { $href = trim( (string) $a->getAttribute( 'href' ) ); if ( '' === $href ) { continue; } $link_host = strtolower( (string) wp_parse_url( $href, PHP_URL_HOST ) ); // No host means a relative path, an anchor or a mailto — none of them // is somewhere else. if ( '' === $link_host || $link_host === $host ) { continue; } $a->setAttribute( 'target', '_blank' ); $rel = trim( (string) $a->getAttribute( 'rel' ) ); $has = preg_split( '/\s+/', $rel, -1, PREG_SPLIT_NO_EMPTY ); foreach ( array( 'noopener', 'noreferrer' ) as $token ) { if ( ! in_array( $token, $has, true ) ) { $has[] = $token; } } $a->setAttribute( 'rel', implode( ' ', $has ) ); $touched = true; } if ( ! $touched ) { return $html; } $out = ''; foreach ( $dom->documentElement->childNodes as $child ) { $out .= $dom->saveHTML( $child ); } return $out; } /** * The word this site uses for ONE of these, for the frames to speak in. * * WordPress already knows it — a type's own singular label — so a Book's * frames say "book" and a Product's say "product" without anything here * guessing. Lowercased, because it lands mid-sentence. * * @param string $type Post type slug. * @return string */ private static function preset_noun( $type ) { $singular = trim( (string) Content::singular( $type ) ); if ( '' === $singular ) { return 'item'; } // A parenthetical is a DISAMBIGUATOR, not part of the noun: FluentCart // labels its singular "Product (FluentCart)" so its own products can be // told from WooCommerce's in a shared admin. Left in, it broke this twice // over — the shape lookup missed "product" entirely, so the two shops got // different frames for the same thing, and the prose read "Describe this // product (fluentcart) for…". The bracket is for lists, never sentences. $singular = trim( (string) preg_replace( '/\s*[\(\[][^\)\]]*[\)\]]\s*/u', ' ', $singular ) ); $singular = trim( (string) preg_replace( '/\s+/u', ' ', $singular ) ); return '' !== $singular ? mb_strtolower( $singular ) : 'item'; } /** * Starting frames for a type that is neither a post nor a page. * * MATCHED ON THE SINGULAR LABEL, never the slug. WooCommerce calls its type * `product` and FluentCart calls its `fluent-products`, but both call ONE of * them a "Product" — the label is the word the site itself uses, and it is * far steadier across plugins than any slug. An unrecognised word simply * falls to the neutral set. * * AND THIS IS EDITORIAL, NOT AN INFERENCE. Everywhere else this plugin * refuses a hardcoded list, it does so because WordPress can answer the * question itself (does this type take categories? does it use the block * editor?). Nothing can tell us what a recipe listing ought to contain — * there is no API for editorial convention. So these are suggestions written * by hand, exactly like the Posts and Pages sets above, for a handful of * shapes almost every site means the same thing by. Anything else gets * frames that cannot be wrong, and the filter below is how a plugin says * what its own type actually needs. * * @param string $noun The type's singular, lowercased. * @return array */ private static function shaped_presets( $noun ) { switch ( $noun ) { case 'product': return array( array( 'label' => __( 'Describe it', 'agentimus' ), 'seed' => sprintf( __( 'Describe this %s for [audience] — what it does, who it suits, and what they get. Plain and specific, no marketing voice. Around 150 words, no headings.', 'agentimus' ), $noun ) ), array( 'label' => __( 'What’s included', 'agentimus' ), 'seed' => sprintf( __( 'What this %s includes and what it does not — a short list, then one paragraph on who it is not for. Leave a [placeholder] wherever I haven’t given you the fact.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Who it’s for', 'agentimus' ), 'seed' => sprintf( __( 'Who this %s is for and who should skip it — the situations it fits, and the ones it does not. Honest, around 150 words.', 'agentimus' ), $noun ) ), array( 'label' => __( 'FAQ', 'agentimus' ), 'seed' => sprintf( __( 'The questions people actually ask before buying this %s, answered plainly — including the awkward ones. One short answer each.', 'agentimus' ), $noun ) ), ); case 'event': return array( array( 'label' => __( 'Describe it', 'agentimus' ), 'seed' => sprintf( __( 'Describe this %s for [audience] — what happens, who it is for, and why it is worth their time. Around 150 words, no headings.', 'agentimus' ), $noun ) ), array( 'label' => __( 'What to expect', 'agentimus' ), 'seed' => sprintf( __( 'What happens at this %s, start to finish — the shape of the day and what someone should be ready for. Leave a [placeholder] for any time, price or address I haven’t given you.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Practical details', 'agentimus' ), 'seed' => sprintf( __( 'The practical details for this %s — getting there, what to bring, accessibility, what is provided. A short list. Invent nothing: leave a [placeholder] for anything I have not told you.', 'agentimus' ), $noun ) ), array( 'label' => __( 'FAQ', 'agentimus' ), 'seed' => sprintf( __( 'The questions people ask before coming to this %s, answered plainly. One short answer each.', 'agentimus' ), $noun ) ), ); case 'recipe': return array( array( 'label' => __( 'Describe it', 'agentimus' ), 'seed' => sprintf( __( 'Introduce this %s — what it tastes like, when to make it, and how much work it really is. Around 120 words, no headings.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Method', 'agentimus' ), 'seed' => sprintf( __( 'The method for this %s as numbered steps, with what to look for at each one rather than only times. Leave a [placeholder] for any quantity I have not given you — never invent an amount.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Tips', 'agentimus' ), 'seed' => sprintf( __( 'What usually goes wrong with this %s and how to avoid it — plus what can be prepared ahead and how to store it.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Variations', 'agentimus' ), 'seed' => sprintf( __( 'Sensible variations on this %s — swaps for common diets or missing ingredients, and which changes affect the result most.', 'agentimus' ), $noun ) ), ); case 'book': return array( array( 'label' => __( 'Describe it', 'agentimus' ), 'seed' => sprintf( __( 'Describe this %s for [audience] — what it is about and what a reader takes away. No spoilers. Around 150 words.', 'agentimus' ), $noun ) ), array( 'label' => __( 'What it covers', 'agentimus' ), 'seed' => sprintf( __( 'What this %s covers, section by section, and roughly how much ground each part takes. Leave a [placeholder] for anything I have not told you.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Who it’s for', 'agentimus' ), 'seed' => sprintf( __( 'Who this %s is for and who will find it too basic or too advanced — and what a reader should already know.', 'agentimus' ), $noun ) ), array( 'label' => __( 'About the author', 'agentimus' ), 'seed' => sprintf( __( 'A short note on the author of this %s — what they do and why they were the one to write it. Invent nothing: leave a [placeholder] for any fact I have not given you.', 'agentimus' ), $noun ) ), ); } // The floor. Each one works whether the type holds a portfolio piece, a // job listing or something nobody here has thought of, and none of them // assumes the owner is blogging. return array( array( 'label' => __( 'Describe it', 'agentimus' ), 'seed' => sprintf( __( 'Describe this %s for [audience] — what it is, who it suits, and what they get. Plain and specific, no marketing voice. Around 150 words, no headings.', 'agentimus' ), $noun ) ), array( 'label' => __( 'FAQ', 'agentimus' ), 'seed' => sprintf( __( 'The questions people actually ask about this %s, answered plainly — including the awkward ones. One short answer each. Leave a [placeholder] wherever I haven’t given you the fact.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Key details', 'agentimus' ), 'seed' => sprintf( __( 'The details someone needs before deciding about this %s — a short list, then one paragraph on who it is not for. Around 200 words.', 'agentimus' ), $noun ) ), array( 'label' => __( 'Announcement', 'agentimus' ), 'seed' => sprintf( __( 'Announce this %s to [audience] — what has changed, why it matters to them, and what to do next. Around 150 words, no hype.', 'agentimus' ), $noun ) ), ); } /** * Whether the assistant proposes illustration slots for a type. * * Three image placeholders down the middle of a piece is an ARTICLE habit. * On a product description it produced exactly what it sounds like: a * shop-owner's product page interrupted by three empty figures — and in a * plugin's own classic editor those render as broken-image icons with the * alt text spilling into the prose. * * So the same discipline as {@see self::terms_for()}: `post` is the one type * whose conventions we can claim to know, pages have always been excluded on * editorial grounds, and everything else is offered nothing rather than * something wrong. The filter reaches this too, for a site that wants them. * * Note this is NOT gated on block-editor support: a FluentCart product * reports `use_block_editor_for_post_type() === true`, so that signal would * have said yes. What is wrong here is the convention, not the storage. * * @param string $type Post type slug. * @param string $shape self::SHAPE_ARTICLE|self::SHAPE_PAGE. * @return bool */ public static function wants_images( $type, $shape = self::SHAPE_ARTICLE ) { if ( self::SHAPE_PAGE === $shape ) { return false; } $type = sanitize_key( (string) $type ); $wants = ( '' === $type || 'post' === $type ); /** * Filter whether the assistant proposes illustration slots for a type. * * @param bool $wants Whether to offer image slots. * @param string $type Post type slug. */ return (bool) apply_filters( 'agentimus_assistant_images', $wants, $type ); } /** * The starting frames offered for a type — fill-in-the-blanks briefs, not * modes: a chip only ever types into the box on the owner's behalf, and the * [brackets] are the point, showing which decisions stay the owner's. * * WHY THEY LIVE HERE and not in the admin app: a WordPress site is not only * a blog. Posts and Pages are the two types whose conventions we can honestly * claim to know; a Product, an Event, a Recipe or a Book is a THING rather * than a piece of writing, and offering "Product review" while someone writes * their own product was not merely unhelpful, it was backwards. So anything * else gets frames that fit any content and can never be wrong for it — and * the filter below lets a commerce plugin, a theme or a site supply frames * that really do fit, without this plugin maintaining a list of every * commerce plugin that exists. ({@see self::shape_for()} refused a hard-coded * type list for the same reason.) * * Server-side also makes them translatable, which they were not while they * sat in the JavaScript as English string literals. * * @param string $type Post type slug. * @return array */ public static function presets_for( $type ) { $type = sanitize_key( (string) $type ); $noun = self::preset_noun( $type ); if ( 'page' === $type ) { $presets = array( array( 'label' => __( 'About', 'agentimus' ), 'seed' => __( 'An About page for [who] — what they do, what they’re known for, and how to get in touch. Warm, specific, not boastful. Short.', 'agentimus' ) ), array( 'label' => __( 'Services', 'agentimus' ), 'seed' => __( 'A Services page covering [service one] and [service two] — what each includes, who it’s for, and what happens after someone gets in touch.', 'agentimus' ) ), array( 'label' => __( 'Contact', 'agentimus' ), 'seed' => __( 'A Contact page — the ways to reach [who], what to expect for a reply, and what to include in a first message.', 'agentimus' ) ), // The placeholder instruction is repeated here on purpose: this is // the one preset where an invented fact becomes a commitment the // owner never made. array( 'label' => __( 'Terms / Policy', 'agentimus' ), 'seed' => __( 'A [Terms of service / Privacy policy] page covering [what it has to cover]. Leave a [placeholder] anywhere I haven’t given you a fact — do not invent a company name, jurisdiction, retention period or guarantee.', 'agentimus' ) ), ); } elseif ( 'post' === $type ) { $presets = array( array( 'label' => __( 'SEO article', 'agentimus' ), 'seed' => __( 'A practical article on [topic] for [audience] — what actually matters, the mistakes people make, and a short checklist to finish. Around 900 words.', 'agentimus' ) ), array( 'label' => __( 'Tutorial', 'agentimus' ), 'seed' => __( 'A step-by-step tutorial showing how to [task], for someone who [level of experience]. Numbered steps, what to expect after each one, and a short troubleshooting section at the end.', 'agentimus' ) ), array( 'label' => __( 'Comparison', 'agentimus' ), 'seed' => __( 'A fair comparison of [A] and [B] for [audience] — where each one wins, where each falls short, and a clear answer on who should pick which.', 'agentimus' ) ), array( 'label' => __( 'Product review', 'agentimus' ), 'seed' => __( 'An honest review of [product] after real use — what it does well, what it doesn’t, who it suits and who should skip it. No marketing voice.', 'agentimus' ) ), ); } else { $presets = self::shaped_presets( $noun ); } /** * Filter the starting frames the writing assistant offers for a type. * * This is the seam for the types this plugin cannot speak for. A commerce * plugin can hand a product type real product frames; a site can replace * the neutral set with its own house shapes. Return [] to offer none. * * @param array $presets Each { label, seed }. Seeds are briefs with * [bracketed] blanks the owner fills in. * @param string $type Post type slug. */ $presets = apply_filters( 'agentimus_assistant_presets', $presets, $type ); $out = array(); foreach ( (array) $presets as $preset ) { if ( ! is_array( $preset ) || empty( $preset['label'] ) || empty( $preset['seed'] ) ) { continue; // A half-written frame is worse than a missing one. } $out[] = array( 'label' => (string) $preset['label'], 'seed' => (string) $preset['seed'], ); } return $out; } /** * Which taxonomies the assistant may fill for a type. * * TWO different questions used to be answered by one: "is this written like a * page?" decided both the prose shape AND whether tags and categories were * asked for. That holds for posts and pages and breaks for everything else — * a FluentCart product is article-shaped, so the model was asked for tags and * categories, produced them, showed them in the preview, and the write then * failed with "this content type does not use categories" AFTER the whole * draft had been paid for. The last possible moment to learn it. * * WordPress already knows the answer, so ask it. The page rule stays on top * as the editorial choice it always was: a standing page is not filed under * taxonomies even where it technically could be. * * @param string $type Post type slug. Empty means "unknown" — callers with * no type keep the old shape-only behaviour. * @param string $shape self::SHAPE_ARTICLE|self::SHAPE_PAGE. * @return array{tags:bool,categories:bool} */ public static function terms_for( $type, $shape = self::SHAPE_ARTICLE ) { if ( self::SHAPE_PAGE === $shape ) { return array( 'tags' => false, 'categories' => false ); } $type = sanitize_key( (string) $type ); if ( '' === $type || ! function_exists( 'is_object_in_taxonomy' ) ) { return array( 'tags' => true, 'categories' => true ); } return array( 'tags' => (bool) is_object_in_taxonomy( $type, 'post_tag' ), 'categories' => (bool) is_object_in_taxonomy( $type, 'category' ), ); } /** * Which of the two shapes a type is written in. * * WordPress already knows the answer, so nothing here is configured and no * list is hard-coded: hierarchical types are standing pages (they nest, they * have no feed, they are rarely filed under taxonomies), and everything else * is an article. A custom type therefore classifies itself correctly the day * it is registered — `docs` and `essays` write as articles, a page-like type * writes as a page — and the filter is there for the type that gets it wrong. * * Two shapes and no more, deliberately. A third ("utility page" for terms and * privacy, say) would need a signal WordPress doesn't give us, which means * tagging pages by hand, which means a setting nobody maintains. * * @param string $type Post type slug. * @return string self::SHAPE_PAGE|self::SHAPE_ARTICLE */ public static function shape_for( $type ) { $type = sanitize_key( (string) $type ); $shape = ( '' !== $type && is_post_type_hierarchical( $type ) ) ? self::SHAPE_PAGE : self::SHAPE_ARTICLE; /** * Filter the shape the assistant writes a type in. * * @param string $shape One of 'article' or 'page'. * @param string $type Post type slug. */ $shape = (string) apply_filters( 'agentimus_assistant_shape', $shape, $type ); return self::SHAPE_PAGE === $shape ? self::SHAPE_PAGE : self::SHAPE_ARTICLE; } /** * The requested type, held to what the owner has made agent-visible. * * An unknown or absent type falls back to 'post' when that is visible, and * otherwise to the first type that is — a site that has switched posts off * still gets a working assistant rather than a 400. * * @param \WP_REST_Request $request Request. * @return string */ public static function read_type( \WP_REST_Request $request ) { $types = Content::post_types(); $type = sanitize_key( (string) $request->get_param( 'type' ) ); if ( in_array( $type, $types, true ) ) { return $type; } return in_array( 'post', $types, true ) ? 'post' : (string) reset( $types ); } /** * 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' ), ) ); // The drawer's own state, re-read when it opens. It arrives in the boot // payload too, but that payload is fixed at page load — so ticking a // content type in Settings left the type chooser showing yesterday's list // until the owner reloaded the page. Deliberately NOT gated on the write // switch: the launcher renders this state in order to explain that writing // is off, and a 403 would leave it explaining nothing. register_rest_route( self::NS, '/assistant/state', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => function () { return rest_ensure_response( $this->state() ); }, 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, ) ); 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/plan', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'plan' ), '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' ), ) ); } /** * 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' ); } /** * 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 self::ai_unavailable_error(); } $text = ( new Assist( $this->settings ) )->generate( self::system_prompt( self::shape_for( self::read_type( $request ) ), self::read_type( $request ) ), $prompt, self::COMPOSE_TOKENS ); if ( is_wp_error( $text ) ) { return self::friendly_ai_error( $text ); } $compose_type = self::read_type( $request ); $draft = self::parse_draft( $text, $compose_type, self::shape_for( $compose_type ) ); 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 self::ai_unavailable_error(); } $text = ( new Assist( $this->settings ) )->generate( self::outline_system_prompt( self::shape_for( self::read_type( $request ) ) ), $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 self::ai_unavailable_error(); } $text = ( new Assist( $this->settings ) )->generate( self::staged_part_system_prompt( self::shape_for( self::read_type( $request ) ) ), 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 self::ai_unavailable_error(); } $text = ( new Assist( $this->settings ) )->generate( self::staged_meta_system_prompt( self::shape_for( self::read_type( $request ) ), self::read_type( $request ) ), self::staged_meta_prompt( $prompt, $outline ), self::STAGED_META_TOKENS ); if ( is_wp_error( $text ) ) { return self::friendly_ai_error( $text ); } $meta_type = self::read_type( $request ); $meta = self::parse_meta( $text, $meta_type, self::shape_for( $meta_type ) ); 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( $shape = self::SHAPE_ARTICLE ) { $page = self::SHAPE_PAGE === $shape; $noun = $page ? 'page' : 'post'; return 'You write ONE PART of a WordPress ' . $noun . '. ' . ( $page ? 'It is a standing page — a fixed part of the site a reader arrives at to get one thing done, not an ' . 'article — so this part earns nothing by being long. ' : '' ) . 'The ' . $noun . ' 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 ' . $noun . ' title, no other sections, no meta commentary. ' . 'Respond with ONLY the part\'s content as clean HTML using only

,

,

,