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 , , , ,
,
,
,
— no
, no images, no markdown fences, no document wrapper. '
. 'The parts are assembled in outline order, so write prose that flows: assume the reader has just read '
. 'the previous part, don\'t re-introduce the ' . $noun . ', and don\'t cover ground the outline assigns elsewhere. '
. 'Write concretely in the brief\'s language; no filler, no invented facts or statistics. '
. ( $page
? 'Stop as soon as this part has done its job, and never pad it to match the others. Where the brief '
. 'leaves a fact out — a jurisdiction, an address, a period — leave a plain [placeholder] rather than '
. 'inventing one; a page that carries obligations must not guess. '
: '' )
. self::readability_rules() . ' '
. 'Voice: follow the site content guidelines above when they declare one (their author, their person, '
. 'their tone are real — use them); never invent credentials, employers or anecdotes the guidelines '
. 'don\'t provide; and when the brief itself specifies a voice, the brief wins. '
. 'With no guidance from either, write in a neutral site voice.';
}
/**
* PURE: the staged-part user prompt — the SPINE (brief + complete outline)
* plus this part's own assignment: which part, its neighbours, its note,
* and its share of any requested length.
*
* @param string $brief The owner's brief.
* @param array $outline The sanitised outline.
* @param string $part intro|section|conclusion.
* @param int $index Section index for part=section.
* @return string
*/
public static function staged_part_prompt( $brief, array $outline, $part, $index = -1 ) {
$sections = $outline['sections'];
$count = count( $sections );
$spine = "The owner's brief:\n" . $brief
. "\n\nThe approved outline for the whole article, as JSON:\n"
. wp_json_encode( $outline, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( 'intro' === $part ) {
return $spine . "\n\nWrite the article's INTRODUCTION: one to three paragraphs that set up the piece "
. 'the outline describes and lead into the first section, "' . $sections[0]['heading'] . '". '
. 'Its FIRST paragraph must be a self-contained two-or-three-sentence summary of the whole '
. 'article — answer engines lift it as the summary. '
. 'No heading of its own — paragraphs only. Don\'t cover the sections\' ground; they follow.';
}
if ( 'conclusion' === $part ) {
return $spine . "\n\nWrite the article's CLOSING: a short ending that lands the piece after its last "
. 'section, "' . $sections[ $count - 1 ]['heading'] . '" — one or two paragraphs. '
. 'No heading of its own. Don\'t open new ground and don\'t mechanically summarise every section.';
}
$section = $sections[ $index ];
$neighbors = 0 === $index
? 'It is the first section — the introduction leads straight into it.'
: 'The section before it is "' . $sections[ $index - 1 ]['heading'] . '".';
if ( $index === $count - 1 ) {
$neighbors .= ' It is the last section — a short closing follows it.';
} else {
$neighbors .= ' The section after it is "' . $sections[ $index + 1 ]['heading'] . '".';
}
return $spine . "\n\nWrite section " . ( $index + 1 ) . ' of ' . $count . ': "' . $section['heading'] . '".'
. ( '' !== $section['note'] ? "\nWhat it covers: " . $section['note'] : '' )
. "\n" . $neighbors
. "\nBegin with exactly
" . $section['heading'] . '
— that heading text verbatim — then the '
. 'section\'s body; subheadings inside it are welcome. '
. 'Write it at natural, complete length for one section of this article; if the brief asks for an '
. 'overall length, aim for this section\'s share of it.';
}
/**
* The staged dressing instruction: everything about the post EXCEPT its body
* — same fields and bounds as the whole-document contract, minus "content",
* with image anchors tied to the outline's own headings.
*
* @return string
*/
public static function staged_meta_system_prompt( $shape = self::SHAPE_ARTICLE, $type = '' ) {
$page = self::SHAPE_PAGE === $shape;
$terms = self::terms_for( $type, $shape );
$images = self::wants_images( $type, $shape );
$noun = $page ? 'page' : 'post';
return 'You prepare the DRESSING for a WordPress ' . $noun . ' that is being written section by section from an '
. 'owner-approved outline — everything about the ' . $noun . ' except its body text. '
. 'Respond with ONLY a single JSON object — no markdown fences, no commentary before or after — with exactly these keys: '
. '"title" (string: the ' . $noun . ' title in Title Case — use the outline\'s title unless the brief itself demands another), '
. '"excerpt" (string, 1–2 plain sentences), '
. '"description" (string: one sentence under 160 characters saying what the page is about, for AI assistants), '
. '"topics" (array of 3–6 short topic phrases), '
// Same split as the whole-document contract: one schema, emptied for a
// page, so the parser never has to know which shape it is holding.
. ( $page
? '"tags" (ALWAYS an empty array — a standing page is not filed under taxonomies), '
. '"categories" (ALWAYS an empty array, for the same reason), '
. '"images" (ALWAYS an empty array — a page carries no illustration slots). '
: ( $terms['tags']
? '"tags" (array of 2–5 tag names), '
: '"tags" (ALWAYS an empty array — this content type does not use tags), ' )
. ( $terms['categories']
? '"categories" (array of 0–2 category names, only if clearly implied by the brief), '
: '"categories" (ALWAYS an empty array — this content type does not use categories), ' )
. ( $images
? '"images" (image SUGGESTIONS — let the piece decide how many, up to 5. Roughly one per section at '
. 'most, fewer when the sections are short, and an empty array when no picture would earn its place: '
. 'each {"alt": a rich, self-contained visual description an image generator could paint from — subject, setting, mood, '
. '"after_heading": the exact text of one of the outline\'s section headings, verbatim, that the image should follow, '
. 'or "" for right after the introduction}). '
: '"images" (ALWAYS an empty array — this content type is not illustrated that way). ' ) )
. 'Ground everything in the brief and the outline; write in the brief\'s language; no invented facts.';
}
/**
* PURE: the staged dressing user prompt — the same spine the parts carry.
*
* @param string $brief The owner's brief.
* @param array $outline The sanitised outline.
* @return string
*/
public static function staged_meta_prompt( $brief, array $outline ) {
return "The owner's brief:\n" . $brief
. "\n\nThe approved outline for the whole article, as JSON:\n"
. wp_json_encode( $outline, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
. "\n\nReturn the dressing JSON for this article.";
}
/**
* PURE: turn the model's text into the sanitised dressing, or a clear error
* — the same fence/prose-tolerant cut every JSON contract uses.
*
* @param string $text Raw model output.
* @return array|\WP_Error
*/
public static function parse_meta( $text, $type = '', $shape = self::SHAPE_ARTICLE ) {
$data = self::json_object( $text );
// ⭐ TRUE: this is the model's own output on its way in.
return self::sanitize_meta( $data, $type, $shape, true );
}
/**
* PURE: hold a dressing-shaped array to the schema — sanitize_draft() minus
* the body, with the same field rules, so the assembled staged draft is
* indistinguishable from a whole-document one.
*
* @param mixed $data Dressing-shaped input.
* @return array|\WP_Error
*
* ⛔ $generated IS THE TITLE-CASE SWITCH, and it defaults to FALSE on purpose
* — his catch 2026-08-21, after typing a banner title in sentence case and
* finding it saved Title Cased. Titles::case()'s own docblock already drew the
* line: "a title an AGENT or the OWNER supplies as content is THEIR text and
* is never run through this." This sanitiser is shared by BOTH directions —
* model output arriving via parse_*(), and payloads the owner edited arriving
* via $request->get_param() — so casing unconditionally here broke that law on
* every request path. ⭐ Only the parse_*() callers pass true; a future caller
* that forgets the flag leaves the owner's text alone, which is the safe way
* round for a rule about not touching what isn't ours.
*/
public static function sanitize_meta( $data, $type = '', $shape = self::SHAPE_ARTICLE, $generated = false ) {
$terms = self::terms_for( $type, $shape );
$images = self::wants_images( $type, $shape );
$title = is_array( $data ) ? sanitize_text_field( (string) ( $data['title'] ?? '' ) ) : '';
if ( '' === $title ) {
return new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return usable details for the post — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
}
return array(
// The standing rule here too — the meta call proposes the title, but
// ONLY when it is the model proposing it. See $generated above.
'title' => $generated ? Titles::case( $title ) : $title,
'excerpt' => sanitize_text_field( (string) ( $data['excerpt'] ?? '' ) ),
'description' => mb_substr( sanitize_text_field( (string) ( $data['description'] ?? '' ) ), 0, 200 ),
'topics' => self::clean_list( $data['topics'] ?? array(), self::MAX_TOPICS ),
// Dropped rather than trusted. The prompt already asks only for what
// the type can hold; a model that returns them anyway must not put a
// row in the preview that the write would then refuse.
'tags' => $terms['tags'] ? self::clean_list( $data['tags'] ?? array(), self::MAX_TAGS ) : array(),
'categories' => $terms['categories'] ? self::clean_list( $data['categories'] ?? array(), self::MAX_CATEGORIES ) : array(),
'images' => $images ? self::clean_image_slots( $data['images'] ?? array() ) : array(),
);
}
/**
* PURE: a staged section opens with its outline heading VERBATIM — the
* anchor image slots and the approved outline both point at. Whatever
* heading the model wrote (right, paraphrased, or none) is replaced with
* ours; a section that was ONLY a heading is refused as empty.
*
* @param string $html The part's clean HTML.
* @param string $heading The outline's heading for this section.
* @return string|\WP_Error
*/
public static function enforce_section_heading( $html, $heading ) {
$body = self::strip_leading_heading( $html );
if ( is_wp_error( $body ) ) {
return $body;
}
return '
' . esc_html( $heading ) . '
' . $body;
}
/**
* PURE: cut ONE leading h2/h3 — the intro and closing never carry a heading
* of their own (the whole-document shape), and a section's model-written
* heading gives way to the verbatim outline heading. Refuses a part that
* was nothing but its heading.
*
* @param string $html The part's clean HTML.
* @return string|\WP_Error
*/
public static function strip_leading_heading( $html ) {
$html = (string) preg_replace( '#\A\s* section per outline entry, in the given order, with the given heading text verbatim. "
. 'Cover what each section\'s note describes. Do not add, remove, merge or reorder sections. '
. 'An introduction before the first section and a short closing after the last are welcome, without headings of their own.';
}
/**
* PURE: turn the model's text into a sanitised outline, or a clear error —
* the same fence/prose-tolerant cut {@see parse_draft()} uses.
*
* @param string $text Raw model output.
* @return array|\WP_Error
*/
public static function parse_outline( $text ) {
$data = self::json_object( $text );
if ( ! is_array( $data ) ) {
return new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return a usable outline — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
}
// ⭐ TRUE: this is the model's own output on its way in.
return self::sanitize_outline( $data, true );
}
/**
* PURE: hold an outline-shaped array to the schema — the shared gate for both
* directions, exactly like {@see sanitize_draft()}: model output on its way
* to the editor, and an owner-edited outline on its way back into the compose
* contract. A section needs a real heading; a heading-less row is dropped,
* and an outline needs a title and at least one surviving section to count.
*
* @param mixed $data Outline-shaped input.
* @return array|\WP_Error
*
* ⛔ $generated IS THE TITLE-CASE SWITCH, and it defaults to FALSE on purpose
* — his catch 2026-08-21, after typing a banner title in sentence case and
* finding it saved Title Cased. Titles::case()'s own docblock already drew the
* line: "a title an AGENT or the OWNER supplies as content is THEIR text and
* is never run through this." This sanitiser is shared by BOTH directions —
* model output arriving via parse_*(), and payloads the owner edited arriving
* via $request->get_param() — so casing unconditionally here broke that law on
* every request path. ⭐ Only the parse_*() callers pass true; a future caller
* that forgets the flag leaves the owner's text alone, which is the safe way
* round for a rule about not touching what isn't ours.
*/
public static function sanitize_outline( $data, $generated = false ) {
$bad = new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return a usable outline — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
if ( ! is_array( $data ) ) {
return $bad;
}
$title = mb_substr( trim( sanitize_text_field( (string) ( $data['title'] ?? '' ) ) ), 0, 160 );
$sections = array();
foreach ( array_slice( (array) ( $data['sections'] ?? array() ), 0, self::MAX_OUTLINE_SECTIONS ) as $section ) {
if ( ! is_array( $section ) ) {
continue;
}
$heading = trim( sanitize_text_field( (string) ( $section['heading'] ?? '' ) ) );
if ( '' === $heading ) {
continue;
}
$sections[] = array(
'heading' => mb_substr( $heading, 0, 120 ),
'note' => mb_substr( trim( sanitize_text_field( (string) ( $section['note'] ?? '' ) ) ), 0, 240 ),
);
}
if ( '' === $title || ! $sections ) {
return $bad;
}
return array(
// The standing rule again: every GENERATED title is Title Case — and
// an outline the owner edited and sent back is not one. See $generated.
'title' => $generated ? Titles::case( $title ) : $title,
'sections' => $sections,
);
}
/**
* POST /assistant/refine — revise the HELD draft with a targeted instruction
* ("add a section on caching", "drop the checklist", "shorten the intro"):
* one AI call, the complete revised document back, still writing nothing.
* The current draft is re-sanitised BEFORE it rides in the prompt, so the
* model only ever sees what the preview showed.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function refine( \WP_REST_Request $request ) {
$instruction = trim( sanitize_textarea_field( (string) $request->get_param( 'instruction' ) ) );
if ( strlen( $instruction ) < 4 ) {
return new \WP_Error(
'agentimus_instruction_short',
__( 'Say what to change first — e.g. “add a short section on caching”.', 'agentimus' ),
array( 'status' => 400 )
);
}
if ( strlen( $instruction ) > 500 ) {
$instruction = substr( $instruction, 0, 500 );
}
$draft = self::sanitize_draft( $request->get_param( 'draft' ), self::read_type( $request ), self::shape_for( self::read_type( $request ) ) );
if ( is_wp_error( $draft ) ) {
return $draft;
}
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 ) ),
self::revision_prompt( $draft, $instruction ),
self::COMPOSE_TOKENS
);
if ( is_wp_error( $text ) ) {
return self::friendly_ai_error( $text );
}
$revise_type = self::read_type( $request );
$revised = self::parse_draft( $text, $revise_type, self::shape_for( $revise_type ) );
if ( is_wp_error( $revised ) ) {
return $revised;
}
return rest_ensure_response( array( 'draft' => $revised ) );
}
/**
* PURE: the revision user-prompt — the current draft as JSON plus the owner's
* instruction, with preserve-everything-else stated explicitly so a one-line
* request can't silently rewrite the whole post.
*
* @param array $draft The sanitised current draft.
* @param string $instruction The owner's change request.
* @return string
*/
public static function revision_prompt( array $draft, $instruction ) {
return "Here is the current draft, as JSON:\n"
. wp_json_encode( $draft, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
. "\n\nRevision request: " . $instruction
. "\n\nReturn the COMPLETE revised draft as the same single JSON object with all the same keys. "
. 'Apply only the requested change; preserve everything else — the title, structure, wording, '
. 'tags, topics and categories — unless the request itself asks for them to change.';
}
/**
* POST /assistant/plan — one instruction about a WHOLE post, answered as a
* list of targeted edits rather than a new document.
*
* This is the endpoint that lets "shorten the whole thing" and "reword
* paragraphs 2 and 9" exist at all, and the shape of it is the reason it
* works where a whole-document rewrite doesn't:
*
* · The planner is shown an OUTLINE — one line per block: number, block
* type, and the first {@see PLAN_EXCERPT} characters. It therefore CANNOT
* rewrite anything, which is deliberate. A planner that could rewrite
* would, and we would be re-emitting whole documents again, with every
* problem that brings.
* · So it returns targets and a per-target INSTRUCTION. The writing itself
* goes back through /assistant/revise-block — one small, proven call per
* accepted edit, already shape-aware, already able to return more blocks
* than it was given. Deletes and moves need no model at all.
* · A block the plan doesn't name is never touched. That is what keeps a
* table, an embed or a separator safe without a compatibility gate: they
* are not "preserved" by careful handling, they are simply not addressed.
*
* Writes nothing. The plan is a proposal; the editor shows it and the owner
* accepts the parts they want.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function plan( \WP_REST_Request $request ) {
$instruction = trim( sanitize_textarea_field( (string) $request->get_param( 'instruction' ) ) );
if ( strlen( $instruction ) < 4 ) {
return new \WP_Error(
'agentimus_plan_empty',
__( 'Say what should change across the post.', 'agentimus' ),
array( 'status' => 400 )
);
}
$outline = self::clean_plan_outline( $request->get_param( 'outline' ) );
if ( ! $outline ) {
return new \WP_Error(
'agentimus_plan_no_content',
__( 'There’s nothing in this post to plan against yet.', 'agentimus' ),
array( 'status' => 400 )
);
}
if ( ! Assist::ai_available() ) {
return self::ai_unavailable_error();
}
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
$text = ( new Assist( $this->settings ) )->generate(
self::plan_system_prompt( self::shape_for( self::read_type( $request ) ) ),
self::plan_prompt( $instruction, $outline, $title ),
self::PLAN_TOKENS
);
if ( is_wp_error( $text ) ) {
return self::friendly_ai_error( $text );
}
$plan = self::parse_plan( $text, count( $outline ) );
if ( empty( $plan['ok'] ) ) {
// Two very different failures wear this one message — a model that
// answered in prose, and one whose JSON was cut off mid-object because
// it thought for longer than the budget allowed. They need opposite
// fixes, so on a debug site the answer's own tail rides along: a string
// ending mid-token is truncation, a string of apology is not.
$data = array( 'status' => 502 );
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
$data['chars'] = mb_strlen( (string) $text );
$data['tail'] = mb_substr( trim( (string) $text ), -160 );
}
return new \WP_Error(
'agentimus_plan_unreadable',
__( 'The plan didn’t come back in a form we could read. Try again, or say it another way.', 'agentimus' ),
$data
);
}
return rest_ensure_response( $plan );
}
/**
* PURE: hold the client's outline to shape and to bounds.
*
* The block NUMBER is the address the whole feature turns on, so it is taken
* from the array position here and echoed back — never trusted from the
* client, and never a client id. The editor resolves numbers to client ids
* on its side at apply time, which is also what makes the plan survive the
* document shifting under it.
*
* @param mixed $raw Raw outline rows.
* @return array
,
,
,
,
. '
. 'Keep everything the instruction does not ask to change EXACTLY as it is, word for word. '
// The same bar every other writing prompt here carries, and it was
// missing from this one — which is the prompt that runs when someone
// asks for "ten more examples". That request is an open invitation to
// produce ten plausible names with confident details attached, and
// this is the only line standing between the invitation and the post.
. 'Write concretely; no filler, and no invented facts, statistics, names, prices or products. If the '
. 'instruction asks for specifics you have no grounds for, say less rather than making them up — a '
. 'shorter true passage beats a longer invented one, every time. '
// The tell of machine-added material is that it starts cold. The post
// travels along as context precisely so this doesn't have to happen:
// the model can see how the existing sections open and hand off.
. 'ADDED MATERIAL MUST JOIN WHAT IS ALREADY THERE. You are given the whole '
. ( $page ? 'page' : 'post' ) . ' as context — read how its existing sections open and follow it. If '
. 'they hand off to each other ("If X feels too loud, Y is my go-to"), then hand off too, naming what '
. 'came before. If they carry a first-person voice, keep it; if they cite the writer\'s own '
. 'experience, do not switch to the detached voice of a catalogue. A new section that opens cold '
. 'reads as pasted in no matter how good its sentences are, and that is the single most common way '
. 'this goes wrong. '
. ( $page
? 'This is a standing page, so do not lengthen it beyond what the instruction asks, and do not introduce '
. 'facts the section did not already carry — on a page that states terms or promises, an invented '
. 'detail is a commitment the owner never made. '
: '' )
. 'No commentary, no markdown fences, no
, no document wrapper. Write in the ' . $noun . '\'s language and voice.';
}
/**
* PURE: the section-revision user prompt — title and capped plain-text
* context for grounding, the selected section, and the instruction.
*
* @param string $title Post title.
* @param string $context The full post as plain text (capped).
* @param string $section The selected section's clean HTML.
* @param string $instruction The owner's change request.
* @return string
*/
public static function revise_block_prompt( $title, $context, $section, $instruction ) {
$parts = array();
if ( '' !== $title ) {
$parts[] = 'Post title: ' . $title;
}
if ( '' !== $context ) {
$parts[] = "The full post, for context only — do NOT return it:\n" . $context;
}
$parts[] = "The selected section (HTML):\n" . $section;
$parts[] = 'Instruction: ' . $instruction;
$parts[] = 'Return only the final version of this section, with any requested additions in place.';
return implode( "\n\n", $parts );
}
/**
* PURE: model text → clean section HTML, or a clear error. Cuts the classic
* markdown fences, then holds the rest to post-safe HTML.
*
* @param string $text Raw model output.
* @return string|\WP_Error
*/
public static function clean_section_html( $text ) {
$text = trim( (string) $text );
if ( preg_match( '/^```[a-z]*\s*(.*?)\s*```$/is', $text, $m ) ) {
$text = $m[1];
}
$html = self::outbound_links_blank( wp_kses_post( $text ) );
if ( '' === trim( wp_strip_all_tags( $html ) ) ) {
return new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return a usable section — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
}
return $html;
}
/**
* POST /assistant/generate-image — one image for one slot, on one explicit
* click. Two-step prompt chain (the good idea from the owner's earlier
* aiwriter plugin): the slot's alt text is a SEED, first rewritten by the text
* model into a vivid scene prompt, then rendered by the image model. The
* result lands in the media library with the ORIGINAL alt (that's the honest
* accessibility text — the scene prompt is generator jargon), EXIF-free by
* WordPress's own import pipeline. Never called in bulk: one slot, one click,
* one image — image generation is priced differently from text and the click
* is the consent.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function generate_image( \WP_REST_Request $request ) {
$alt = trim( sanitize_text_field( (string) $request->get_param( 'alt' ) ) );
if ( mb_strlen( $alt ) < 5 ) {
return new \WP_Error(
'agentimus_alt_short',
__( 'Describe the image in a sentence first.', 'agentimus' ),
array( 'status' => 400 )
);
}
$title = trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) );
if ( ! self::image_ready() ) {
return new \WP_Error(
'agentimus_image_unavailable',
__( 'Your connected AI provider can’t generate images. Pick one from the media library instead, or connect an image-capable provider under Settings → AI.', 'agentimus' ),
array( 'status' => 503 )
);
}
// Step 1 — scene description (a TEXT call: rides the same rate limit as
// every other generation, which also bounds the paired image call below).
$scene = ( new Assist( $this->settings ) )->generate(
self::scene_system_prompt(),
( '' !== $title ? 'Article: ' . $title . "\n" : '' ) . 'Image description: ' . $alt,
self::SCENE_TOKENS
);
if ( is_wp_error( $scene ) ) {
return self::friendly_ai_error( $scene );
}
// Step 2 — the one image call. A model PREFERENCE (not a pin) rides
// along: the AI Client's default pick is the newest image model, which
// free tiers often have zero quota for, while an older stable sibling
// works — preferred models are tried in order and unknown ids fall
// back to the client's own choice, so a non-Google provider is never
// broken by this. Filterable for sites that know better.
try {
$preferred = apply_filters( 'agentimus_assistant_image_models', array( 'gemini-2.5-flash-image' ) );
$builder = wp_ai_client_prompt( $scene );
if ( is_array( $preferred ) && $preferred ) {
$builder = $builder->using_model_preference( ...array_values( $preferred ) );
}
$file = $builder->generate_image();
} catch ( \Throwable $e ) {
$file = new \WP_Error( 'agentimus_image_failed', $e->getMessage() );
}
if ( is_wp_error( $file ) ) {
return self::friendly_image_error( $file );
}
$attachment_id = self::import_image_file( $file, $alt );
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
return rest_ensure_response(
array(
'id' => $attachment_id,
'url' => (string) ( wp_get_attachment_image_url( $attachment_id, 'medium' ) ?: wp_get_attachment_url( $attachment_id ) ),
'fullUrl' => (string) wp_get_attachment_url( $attachment_id ),
'alt' => $alt,
)
);
}
/**
* The scene-describer instruction: turn a slot's alt seed into one concrete
* image prompt. Blog-appropriate styles only; text-in-image banned (models
* mangle it, and the article already carries the words).
*
* @return string
*/
public static function scene_system_prompt() {
return 'You turn a short description of a blog-article image into ONE vivid, concrete image-generation prompt. '
. 'Describe subject, setting, composition, lighting and style in a single plain-text paragraph. '
. 'Prefer clean editorial illustration or natural photography suited to a professional blog. '
. 'No text, lettering, watermarks, logos or UI screenshots in the image. Return ONLY the prompt paragraph.';
}
/**
* Import a generated image (inline base64 or remote URL) through WordPress's
* own sideload pipeline — validation, size variants, clean metadata — and
* stamp the slot's alt on it. Temp files are cleaned on every path.
*
* @param object $file The AI client's File DTO.
* @param string $alt The slot's alt text.
* @return int|\WP_Error Attachment ID.
*/
private static function import_image_file( $file, $alt ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$mime = method_exists( $file, 'getMimeType' ) ? (string) $file->getMimeType() : 'image/png';
$exts = array(
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/gif' => 'gif',
);
$ext = isset( $exts[ $mime ] ) ? $exts[ $mime ] : 'png';
if ( method_exists( $file, 'isInline' ) && $file->isInline() ) {
$data = base64_decode( (string) $file->getBase64Data(), true );
if ( false === $data || '' === $data ) {
return new \WP_Error( 'agentimus_image_failed', __( 'The generated image came back unreadable — please try again.', 'agentimus' ), array( 'status' => 502 ) );
}
$tmp = wp_tempnam( 'agentimus-image.' . $ext );
if ( ! $tmp || false === file_put_contents( $tmp, $data ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions -- controlled temp file.
return new \WP_Error( 'agentimus_image_failed', __( 'Couldn’t stage the image for import.', 'agentimus' ), array( 'status' => 500 ) );
}
} else {
$tmp = download_url( (string) $file->getUrl() );
if ( is_wp_error( $tmp ) ) {
return $tmp;
}
}
$name = sanitize_file_name( 'assistant-' . sanitize_title( mb_substr( $alt, 0, 48 ) ) . '.' . $ext );
$attachment = media_handle_sideload(
array(
'name' => $name,
'tmp_name' => $tmp,
),
0,
mb_substr( $alt, 0, 120 ) // Attachment title = the human description.
);
if ( is_wp_error( $attachment ) ) {
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions -- best-effort temp cleanup.
return $attachment;
}
update_post_meta( (int) $attachment, '_wp_attachment_image_alt', $alt );
return (int) $attachment;
}
/**
* POST /assistant/create — materialise a previewed draft through the governed
* write path. The owner clicked the button; this is the ONLY place the
* assistant writes, and it can only ever produce a draft or a pending post.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function create( \WP_REST_Request $request ) {
// v1 never publishes — clamp BEFORE ContentWriter even sees the status, so
// the assistant can't go live even on a site whose publish rung is on.
$status = 'pending' === (string) $request->get_param( 'status' ) ? 'pending' : 'draft';
$input = array(
// Held to the agent-visible list rather than taken on trust: read_type
// falls back to a type the owner has actually allowed, so a stale or
// hand-crafted request can't materialise content in a type the rest of
// the plugin doesn't consider its business.
'type' => self::read_type( $request ),
'title' => sanitize_text_field( (string) $request->get_param( 'title' ) ),
// Applied here too, not only at compose: a draft stashed in the
// browser before this rule existed still reaches the post through
// this door.
'content' => self::outbound_links_blank( wp_kses_post( (string) $request->get_param( 'content' ) ) ),
'status' => $status,
);
$excerpt = sanitize_text_field( (string) $request->get_param( 'excerpt' ) );
if ( '' !== $excerpt ) {
$input['excerpt'] = $excerpt;
}
$description = sanitize_text_field( (string) $request->get_param( 'description' ) );
if ( '' !== $description ) {
$input['description'] = $description;
}
// The last gate, and the one that has to hold: a preview can outlive the
// rule that produced it. A draft composed before this check existed — or
// simply stashed in the browser for a week — still carries the terms it
// was given, and the owner would meet the refusal at the only moment that
// costs them the whole draft. Dropped here instead, quietly, because a
// term the type cannot hold was never going to be part of this post.
//
// ContentWriter still refuses them, and must: an AGENT sending categories
// to a type that has none has made a mistake worth reporting. This is the
// assistant's own door, where the terms were our suggestion, not theirs.
$terms = self::terms_for( $input['type'], self::shape_for( $input['type'] ) );
foreach ( array( 'topics', 'tags', 'categories' ) as $list ) {
if ( isset( $terms[ $list ] ) && ! $terms[ $list ] ) {
continue;
}
$values = self::clean_list( $request->get_param( $list ), 'topics' === $list ? self::MAX_TOPICS : ( 'tags' === $list ? self::MAX_TAGS : self::MAX_CATEGORIES ) );
if ( $values ) {
$input[ $list ] = $values;
}
}
// Featured image: an attachment the owner chose or generated in the preview.
// ContentWriter re-validates it (is it an image, does the type support one).
$featured = absint( $request->get_param( 'featured_image' ) );
if ( $featured > 0 ) {
$input['featured_image'] = (string) $featured;
}
// Every proposed image lands in the article: a filled slot as its real
// figure, a bare suggestion as an alt-only PLACEHOLDER block — the
// editor is the image workshop (fill, generate, or delete it there).
$figures = self::figures_from_slots( $request->get_param( 'images' ) );
if ( $figures ) {
$input['content'] = self::inject_images( $input['content'], $figures );
}
// The editor should open REAL blocks, not a Classic block with a
// "Convert to blocks" chore — figures included, so this runs last.
$input['content'] = self::blockify( $input['content'] );
$result = ( new ContentWriter( $this->settings ) )->create( $input );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( array( 'post' => $result ) );
}
/**
* GET /assistant/posts — the picker's search: the owner's own editable posts
* and pages, each carrying the address of its editor.
*
* A LAUNCHER, not a workspace. The drawer owns the blank page — brief,
* outline, draft, create — and hands anything that already exists to the
* block editor, which is where such a thing lives. That division is what
* retired the compatibility verdict this list used to carry: the drawer had
* to refuse a page containing a separator, because rewriting a whole
* document through a schema that only covers what the assistant itself
* writes destroys everything it doesn't recognise. The editor recognises
* every block, so the question stops being asked.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response
*/
public function posts( \WP_REST_Request $request ) {
$q = trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) );
// An empty or unknown filter means every agent-visible type — the list's
// own default. Only a type the owner has actually made visible narrows it,
// so the filter can never widen what the picker is allowed to show.
$filter = sanitize_key( (string) $request->get_param( 'type' ) );
$types = Content::post_types();
$scope = in_array( $filter, $types, true ) ? $filter : $types;
$query = new \WP_Query(
array(
'post_type' => $scope,
'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ),
's' => $q,
// Over-fetch, then trim to PICKER_ROWS after the capability check
// below. Asking for exactly ten and then dropping the ones this
// user can't edit is how a line promising ten shows six: the
// filtering happens in PHP, not in the query, so the query has to
// hand over spares. Three times is generous enough for an author
// among editors without turning the picker into a report.
'posts_per_page' => self::PICKER_ROWS * 3,
'orderby' => '' === $q ? 'modified' : 'relevance',
'order' => 'DESC',
'no_found_rows' => true,
)
);
$rows = array();
foreach ( $query->posts as $post ) {
if ( count( $rows ) >= self::PICKER_ROWS ) {
break;
}
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
continue;
}
$rows[] = array(
'id' => $post->ID,
'title' => '' !== $post->post_title ? $post->post_title : __( '(no title)', 'agentimus' ),
'status' => $post->post_status,
'statusLabel' => self::status_label( $post->post_status ),
'date' => get_the_modified_date( '', $post ),
// Where the row goes: the real editor, which is where a post that
// already exists is edited. No compatibility verdict rides along
// any more — the list stopped judging when it stopped rewriting.
// A block this assistant can't write is no longer any of its
// business, because the editor can hold every one of them.
'editUrl' => (string) get_edit_post_link( $post->ID, 'raw' ),
// The row says which type it is, so a mixed list reads honestly and
// the client can send the right type back when it revises this one.
'type' => $post->post_type,
'typeLabel' => Content::label( $post->post_type ),
'typeSingular' => Content::singular( $post->post_type ),
);
}
return rest_ensure_response( array( 'posts' => $rows ) );
}
/**
* A post status as the short human label the drawer shows.
*
* @param string $status Status slug.
* @return string
*/
private static function status_label( $status ) {
$labels = array(
'publish' => __( 'Published', 'agentimus' ),
'draft' => __( 'Draft', 'agentimus' ),
'pending' => __( 'Pending review', 'agentimus' ),
'private' => __( 'Private', 'agentimus' ),
'future' => __( 'Scheduled', 'agentimus' ),
);
return isset( $labels[ $status ] ) ? $labels[ $status ] : $status;
}
/**
* PURE: the writing rules behind the AI Readability checks ({@see PageCheck}),
* as one instruction paragraph every article-writing prompt carries — so a
* fresh draft opens with its meta-box checks green instead of flagging what
* the writer could have done right the first time. Only the rules a WRITER
* can honour ride here (paragraph length, plain sentences, specifics,
* sources); featured image, freshness and friends are editor-time facts.
* The paragraph cap derives from PageCheck's own threshold, so the prompt
* and the check can never drift apart.
*
* @return string
*/
public static function readability_rules() {
return 'Write to pass the site\'s AI-readability checks: keep every paragraph under about '
. ( PageCheck::LONG_PARAGRAPH_WORDS - 40 ) . ' words — never longer than '
. ( PageCheck::LONG_PARAGRAPH_WORDS - 10 ) . ' — so an engine can lift a clean, self-contained passage; '
. 'prefer short sentences and plain words over long, clause-heavy ones; '
. 'ground claims in concrete specifics — figures, names, versions — that the brief or well-established '
. 'knowledge provides, never invented ones; and where a claim leans on official documentation or a '
. 'well-known source, link it inline with — only addresses you are certain exist, never a guessed '
. 'URL, and only a few: the article must stay prose, not a link list.';
}
/**
* The compose system instruction: one JSON document, keys fixed, body as clean
* post HTML. Kept strict so parsing is boring; the site's Content Guidelines
* are layered on top by {@see Assist::generate()}, not here.
*
* @return string
*/
public static function system_prompt( $shape = self::SHAPE_ARTICLE, $type = '' ) {
$page = self::SHAPE_PAGE === $shape;
$terms = self::terms_for( $type, $shape );
$images = self::wants_images( $type, $shape );
return 'You are the writing assistant inside a WordPress site\'s admin, drafting a '
. ( $page ? 'standing PAGE' : 'post' ) . ' from the owner\'s brief. '
. ( $page
? 'A page is not an article: it is a fixed part of the site — About, Services, Terms, Contact — that a '
. 'reader arrives at to get one thing done. It is not dated, not filed, not part of a series, and it '
. 'earns nothing by being long. '
: '' )
. 'Respond with ONLY a single JSON object — no markdown fences, no commentary before or after — with exactly these keys: '
. '"title" (string), '
. '"excerpt" (string, 1–2 plain sentences), '
. '"content" (string: the full ' . ( $page ? 'page' : 'post' ) . ' body as clean HTML using only
,
,