WP Manifestindependent plugin directory
manifest / ai / editorial-assistant

Editorial Assistant

AI editorial assistant for WordPress publishers: headline variants, internal links, multi-turn chat, and block rewrites.

by (your team) · github.com/alansmodic/editorial-assistant

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/alansmodic/editorial-assistant/archive/refs/heads/main.zip

An AI editorial assistant for WordPress publishers. Lives in the block-editor sidebar and helps writers ship better drafts faster — suggesting headlines, surfacing internal links from the archive, rewriting blocks on request, and holding a multi-turn chat scoped to the post in front of you.

Every suggestion is a proposal, never a silent mutation. Accept what's useful, reject the rest. Decisions persist across reloads.


Table of contents


What it does

Editorial Assistant adds a sidebar to the WordPress block editor with three collapsible panels:

Panel What it gives you
Headlines Five distinct angle variants (news / analysis / feature / service / wildcard) with rationale and tradeoffs. One-click apply to the post title.
Internal links Up to six high-confidence link suggestions filtered from a scored candidate list. One-click splice into the matching block, preserving inline formatting.
Chat Multi-turn streaming chat scoped to the current post. Quote blocks for context, ask for rewrites, request link searches conversationally.

It is built on top of the WordPress AI Client (wp_ai_client_prompt()) and registers with the Agents API substrate when present. Without Agents API the plugin still works — REST endpoints and the sidebar exercise the AI client directly.

Requirements

  • WordPress 7.0+wp_ai_client_prompt() must be available
  • PHP 8.1+ — strict types, str_starts_with, readonly properties
  • Node 20+ — only needed to build the sidebar JS
  • A configured AI provider under Settings → AI Credentials
  • (Optional) the Agents API plugin — enables agent registration; the plugin degrades gracefully without it

Installation

  1. Drop the plugin into wp-content/plugins/editorial-assistant/.
  2. (Recommended) install the Agents API plugin from its GitHub releases.
  3. Build the sidebar JS:
    cd wp-content/plugins/editorial-assistant
    npm install
    npm run build
  4. Activate Editorial Assistant in WordPress admin. Activation runs four things:
    • Creates the editorial_assistant_pending_actions DB table
    • Flushes rewrite rules so /editorial-assistant/chat-stream resolves
    • Schedules the weekly authority-index drift-recovery cron
    • Schedules the daily pending-action cleanup cron
  5. Configure an AI provider under Settings → AI Credentials.
  6. Visit Settings → Editorial Assistant to review and edit the publication style guide.

Upgrading from v0.2 — deactivate + reactivate to pick up the new schema and cron schedule.

Configuration

AI provider

Editorial Assistant uses whichever provider is configured under Settings → AI Credentials (provided by the AI Client). No provider keys are stored by this plugin.

Style guide

Settings → Editorial Assistant exposes the publication style guide — tone, banned words, house preferences. The style guide is interpolated into every prompt by Conversation\ContextComposer, so changes apply immediately on the next request.

Using the assistant

Open any post in the block editor. The Editorial Assistant icon appears in the top-right toolbar; click it to open the sidebar. All three panels are collapsible; only the one you expand makes network calls.

Headlines

  1. Click Suggest headlines.
  2. Five variant cards appear, each with a different editorial angle, a one-line rationale, and a tradeoffs note.
  3. Accept sets the post title (and is remembered in voice memory). Reject dismisses the card.

Pending variants survive a page reload — close the tab, come back, and the unresolved cards are still there.

Internal links

  1. Click Find related links.
  2. The plugin runs a four-stage pipeline:
    1. Anchor extraction — named entities, recurring noun phrases, and "as we reported" backreferences are pulled from the draft.
    2. Candidate retrieval — the archive is searched for matching published posts.
    3. Scoring — each candidate gets 0.5 × relevance + 0.2 × recency + 0.15 × authority + 0.15 × anchor strength.
    4. Model filter — top ~10 candidates are handed to the model with an editor-quality bar; up to 6 high-confidence picks come back.
  3. Each result is an accept/reject card showing the anchor, the target, and the model's rationale.
  4. Insert link splices an <a> into the first matching block using @wordpress/rich-text's applyFormat(), preserving the surrounding bold/italic/link formatting.

Authority (inbound link count) updates incrementally on every save_post, so scores stay current without a nightly batch. The weekly full rebuild catches drift if something slips through.

Chat

A multi-turn streaming chat panel scoped to the current post. The transcript is stored in post meta and auto-resumes when you reopen the post — including any pending tool-call cards from a previous session, which re-render under a "Resumed" message.

Examples:

  • find related links
  • what angle am I missing?
  • Quote a block, then: make this tighter or remove the hedging

Quote selected block — select a block in the editor, click the button, the block's text becomes context for your next message. This is required for rewrite_block tool calls.

Streaming uses Server-Sent Events over a custom non-REST endpoint. If your host buffers SSE (some reverse proxies do), the client falls back to a buffered REST call after 3 seconds — same events, same shape, just batched. See REST and streaming API for the contract.

Block rewrites

When you quote a block and ask for a change, the model calls the rewrite_block tool. The result renders as a side-by-side word-level diff:

  • Original on top with removed words struck through
  • Rewritten below with added words highlighted

Apply rewrite replaces the block's text; Keep original dismisses.

Two safety nets:

  • Similarity check — if the model returns essentially the same text (>95% word overlap), the card is suppressed with a "try a more specific instruction" message rather than wasting a click.
  • Formatting reset warning — applying a rewrite writes to the block's text attribute, which clears inline bold / italic / inline links in the rewritten paragraph. This is surfaced in the card so writers aren't surprised.

How proposals are persisted

Every accept/reject decision — headline, link, or rewrite — flows through one endpoint (POST /editorial-assistant/v1/actions/resolve) backed by a custom DB table (editorial_assistant_pending_actions).

The schema mirrors the Agents API WP_Agent_Pending_Action value object, so the data is in the right shape for a future migration to the substrate's store interface.

Lifecycle:

  • Created when a tool produces a proposal
  • Expires after 7 days if undecided
  • Resolved (accepted or rejected) records prune after 30 days via the daily cleanup cron

This is also what powers rehydration: when any panel mounts, it queries the store for the current post and re-renders undecided proposals as fresh cards. There's no React-only ephemeral state for suggestions.

Architecture

editorial-assistant.php           Plugin bootstrap: hooks, autoloader, activation
src/
  Agent.php                       Agents API registration (modes, tool & action policy)
  Approvals/
    PendingActionStore.php        DB-backed pending-action store (substrate-compatible schema)
  REST/
    HeadlinesController.php       /v1/headlines/{suggest,accept}
    ChatController.php            /v1/chat/{transcript,clear,send},
                                  /v1/links/{suggest,accept},
                                  /v1/actions/resolve
  Streaming/
    EventSink.php                 Interface — both emitter and buffer satisfy this
    EventEmitter.php              SSE emitter with proxy-flush padding
    BufferingEventSink.php        In-memory accumulator for the non-streaming fallback
    ChatStreamEndpoint.php        Custom POST /editorial-assistant/chat-stream
  Conversation/
    ContextComposer.php           Assemble system prompt from draft + style + voice
    ToolRegistry.php              Tool declarations + dispatch
    StreamingRunner.php           Per-turn loop with idempotency replay
  Linking/
    AnchorExtractor.php           Stage 1: pull anchor candidates from draft
    AuthorityIndex.php            Incremental inbound-link counting + weekly rebuild
    CandidateRanker.php           Stages 2-3: retrieve + score targets
    LinkProposer.php              Stage 4: model filter + persist
  Tools/
    HeadlineGenerator.php         Headline variants + Tier A validation + persist
    BlockRewriter.php             Block rewrite + similarity check + persist
  StyleGuide/Repository.php       Publication style guide
  Memory/VoiceMemory.php          Per-user accepted-headline memory
  Settings/StyleGuideAdmin.php    Settings → Editorial Assistant screen
  Chat/TranscriptStore.php        Per-post chat history in post_meta

src-js/sidebar/
  index.js                        Sidebar registration with three panels
  HeadlinePanel.jsx               Headlines panel (with rehydration)
  VariantCard.jsx                 Headline variant card
  linking/
    LinksPanel.jsx                "Find related links" + results
    LinkCard.jsx                  Per-link accept/reject card
  chat/
    ChatPanel.jsx                 Chat UI, transcript+pending rehydration, streaming
    streamReader.js               POST-fetch SSE reader with 3s timeout → REST fallback
    RewriteCard.jsx               Word-level diff display + apply/keep
  shared/
    blockMutations.js             findAnchorInBlocks, insertLinkInBlocks, getSelectedBlockText

build/sidebar/                    Compiled JS (npm run build)

Design notes

One resolve endpoint. Headlines, links, and rewrites all share the same persistence path. Adding a new tool means returning a pending action — no new resolve plumbing.

Streaming with a buffered fallback. Streaming\EventSink is implemented by both EventEmitter (SSE) and BufferingEventSink (in-memory). The same Conversation\StreamingRunner runs against either. The frontend's streamReader.js arms a 3-second timer; if no event arrives, it aborts and POSTs to the buffered REST fallback. Client UI handling is identical either way.

Idempotency. Because stream-then-fallback can result in two requests with the same payload, the client mints a per-send request_id. The server tags the user message in transcript with it, and any second request with the same request_id replays from transcript rather than re-prompting the model. This protects against double-billing.

Incremental authority indexing. Linking\AuthorityIndex listens on three hooks:

  • save_post — diffs previous vs current outbound links and adjusts target counts
  • transition_post_status — adds or subtracts the whole outbound set on publish/unpublish
  • before_delete_post — removes the post's outbound contribution

The weekly full rebuild exists as drift recovery, not as the primary index path.

Block rewrites preserve meaning, not formatting. The rewriter prompt instructs the model to keep the writer's voice and invent no new claims. A post-hoc word-overlap similarity check suppresses no-op rewrites. Applying a rewrite uses the block's text attribute directly — inline formatting is reset and the UI says so.

REST and streaming API

All REST routes are registered under the editorial-assistant/v1 namespace. Authentication uses the standard WordPress REST nonce; permission callbacks check edit_post on the relevant post.

Headlines

Method Route Purpose
POST /editorial-assistant/v1/headlines/suggest Generate five headline variants for a post
POST /editorial-assistant/v1/headlines/accept Apply a variant to the post title and record voice memory

Chat

Method Route Purpose
GET /editorial-assistant/v1/chat/transcript Load the transcript + any pending tool-call cards for a post
POST /editorial-assistant/v1/chat/clear Clear the transcript
POST /editorial-assistant/v1/chat/send Non-streaming fallback for a chat turn (buffered SSE replay)

Links

Method Route Purpose
POST /editorial-assistant/v1/links/suggest Run the four-stage link pipeline for a post
POST /editorial-assistant/v1/links/accept Mark a link suggestion as accepted (insertion happens client-side)

Actions

Method Route Purpose
POST /editorial-assistant/v1/actions/resolve Accept or reject any pending action by ID

Streaming endpoint

Method Route Purpose
POST /editorial-assistant/chat-stream SSE stream of a chat turn (not a REST route)

This endpoint sits outside the REST API on purpose — it needs raw control over headers and flushing for SSE. It carries its own nonce (editorialAssistant.streamNonce, exposed via wp_localize_script).

Event shape. Each data: line is a JSON envelope { type, payload } where type is one of: assistant.delta, tool.call, tool.result, action.proposed, turn.complete, error. The buffered fallback emits the same envelopes in batch, so the client folds them into the UI identically.

Style guide and voice memory

Style guide (StyleGuide\Repository) — site-wide. Edited under Settings → Editorial Assistant. Interpolated into the system prompt by Conversation\ContextComposer.

Voice memory (Memory\VoiceMemory) — per-user. Stores the last N accepted headlines so the model can lean toward the writer's existing voice on future suggestions. Currently backed by user_meta; planned migration to the Agents API memory store with workspace scope + provenance.

Cron jobs

Hook Schedule Purpose
editorial_assistant_authority_rebuild weekly Full rebuild of the inbound-link authority index — drift recovery
editorial_assistant_pending_action_cleanup daily Prunes resolved pending actions older than 30 days; expires undecided actions after 7 days

Both are scheduled at activation and cleared at deactivation.

Development

npm install
npm run build      # Production build → build/sidebar/
npm run start      # Watch mode (use during sidebar JS work)
npm run lint:js    # @wordpress/scripts ESLint
npm run format     # @wordpress/scripts formatter

PHP is autoloaded by a tiny PSR-4-ish autoloader in editorial-assistant.php mapping EditorialAssistant\src/. No Composer.

Conventions:

  • One class per file, namespaced EditorialAssistant\<Subpath>, path matches namespace.
  • Every PHP file starts with declare( strict_types=1 );.
  • New tools live in src/Tools/, are wired into Conversation\ToolRegistry, and persist proposals through Approvals\PendingActionStore.

There is no PHP test suite in this iteration. After any schema or cron change, deactivate + reactivate the plugin to pick up the new state.

Troubleshooting

"wp_ai_client_prompt() is not available" admin notice The AI Client component of WordPress 7.0+ isn't enabled, or you're on an older WordPress version. The sidebar will not function until this is resolved.

Streaming never starts; everything arrives in a batch Your host (or a reverse proxy in front of it) is buffering SSE. The 3-second fallback to the buffered REST endpoint is doing its job — functionality is preserved but you lose the streaming feel. Common culprits: nginx without proxy_buffering off, Cloudflare on a non-Enterprise plan, certain shared hosts.

Suggestions disappear after refresh They shouldn't — they're persisted in the pending-action store and rehydrated on mount. If they don't come back, check that activation ran cleanly (the editorial_assistant_pending_actions table must exist). Deactivate + reactivate is the safe fix.

Link suggestions feel stale The authority index updates incrementally on save_post. If you've imported a large archive or done bulk edits outside the editor, run wp cron event run editorial_assistant_authority_rebuild to force a rebuild rather than waiting for the weekly cron.

Two chat replies for one message Indicates the idempotency request_id isn't reaching the server. Hard-refresh the editor to reload the sidebar JS — older cached builds predate the idempotency contract.

Agent isn't registered The Agents API plugin isn't installed/active. The rest of Editorial Assistant continues to work; agent registration is a forward-compatibility step.

Roadmap

  • rewrite_lede variant that auto-finds the first paragraph instead of requiring a quoted block
  • Shared proposals so two writers editing the same draft see each other's pending cards
  • Tier C exemplars in the style guide (positive examples, not just rules)
  • Voice memory migration to the Agents API memory store with workspace scope + provenance
  • New toolsfind_archive (semantic archive search), draft_diagnose (structural critique), extract_quotes (pull-quote candidates)
  • Per-feature opt-in/opt-out preferences (chat on, headlines off, etc.)

License

GPL-2.0-or-later