diff --git a/apps/cli/ai/agent.ts b/apps/cli/ai/agent.ts index acb8c08e8b..ca60e69f0b 100644 --- a/apps/cli/ai/agent.ts +++ b/apps/cli/ai/agent.ts @@ -83,6 +83,7 @@ export function startAiAgent( config: AiAgentConfig ): Query { name: activeSite.name, url: activeSite.url ?? '', id: activeSite.wpcomSiteId!, + planSlug: activeSite.planSlug, }, } : undefined; diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index 77c52cd21a..2f1c7d1612 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -2,6 +2,7 @@ interface RemoteSiteContext { name: string; url: string; id: number; + planSlug?: string; } const AGENT_IDENTITY = `You are WordPress Studio Code, the AI agent built into WordPress Studio CLI. Your name is "WordPress Studio Code".`; @@ -27,9 +28,11 @@ ${ LOCAL_DESIGN_GUIDELINES } function buildRemoteIntro( site: RemoteSiteContext ): string { return `${ AGENT_IDENTITY } You manage WordPress.com sites using the WordPress.com REST API. -IMPORTANT: The active site is a remote WordPress.com site: "${ site.name }" (ID: ${ site.id }) at ${ site.url }. +IMPORTANT: The active site is a remote WordPress.com site: "${ site.name }" (ID: ${ site.id }) at ${ + site.url + }.${ site.planSlug ? ` Plan: ${ site.planSlug }.` : '' } IMPORTANT: You MUST use the wpcom_request tool (prefixed with mcp__studio__) to manage this site. Do NOT use WP-CLI, file Write/Edit, Bash, or any local file operations — this site is hosted on WordPress.com and cannot be modified through the local filesystem. -IMPORTANT: Before doing ANY work, you MUST first check the site's plan by calling \`GET /\` (apiNamespace: \`""\`). The \`plan.product_slug\` field indicates the plan. If the site is on a free plan (e.g. \`free_plan\`), you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. Do not proceed with the design task. +IMPORTANT: If the site is on a free plan, you MUST refuse design customization requests — this includes custom CSS, inline styles, style attributes on blocks, global styles editing, custom JavaScript, animations, custom colors/fonts/layouts, and plugin management. Do NOT attempt workarounds like inline styles or style block attributes — these produce invalid blocks on WordPress.com. Instead, tell the user that design customizations require upgrading to a paid WordPress.com plan and STOP. Do not proceed with the design task. ## Available Tools (prefixed with mcp__studio__) @@ -77,7 +80,7 @@ Use \`per_page\` and \`page\` for pagination. Use \`status\` to filter by publis ## Workflow -1. **Check the site plan** (MANDATORY FIRST STEP): Use \`GET /\` (apiNamespace: \`""\`) to get site info and check \`plan.product_slug\`. Stop and inform the user if they request features unavailable on their plan. +1. **Check the site plan**: The plan is already provided in your active site context. If it is a free plan, refuse design customization requests as described above. 2. **Understand the site**: Use \`GET /posts\` to list content, \`GET /themes?status=active\` to see the active theme. 3. **Make changes**: Use POST requests to create/update content, manage templates, switch themes. 4. **Verify visually**: Use take_screenshot to capture the site on desktop and mobile viewports. Check spacing, alignment, colors, contrast, and layout. Fix any issues. diff --git a/apps/cli/ai/tests/wpcom-tools.test.ts b/apps/cli/ai/tests/wpcom-tools.test.ts new file mode 100644 index 0000000000..bc97c2f33f --- /dev/null +++ b/apps/cli/ai/tests/wpcom-tools.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { stripNoisyFields } from '../wpcom-tools'; + +describe( 'stripNoisyFields', () => { + it( 'removes _links and other noisy top-level fields', () => { + const input = { + id: 1, + title: 'Hello', + _links: { self: '/posts/1' }, + _embedded: { author: [] }, + guid: { rendered: 'http://example.com/?p=1' }, + ping_status: 'open', + comment_status: 'open', + generated_slug: 'hello', + permalink_template: 'http://example.com/%postname%/', + }; + const { data, removed } = stripNoisyFields( input ); + expect( data ).toEqual( { id: 1, title: 'Hello' } ); + expect( removed ).toContain( '_links' ); + expect( removed ).toContain( 'guid' ); + expect( removed ).toContain( 'permalink_template' ); + } ); + + it( 'drops rendered when raw is present in the same object', () => { + const input = { + title: { raw: 'Hello', rendered: '

Hello

' }, + content: { raw: '', rendered: '

rendered html

' }, + }; + const { data, removed } = stripNoisyFields( input ); + expect( data ).toEqual( { + title: { raw: 'Hello' }, + content: { raw: '' }, + } ); + expect( removed ).toContain( 'rendered (raw exists)' ); + } ); + + it( 'keeps rendered when raw is absent', () => { + const input = { title: { rendered: 'Hello' } }; + const { data } = stripNoisyFields( input ); + expect( data ).toEqual( { title: { rendered: 'Hello' } } ); + } ); + + it( 'recursively strips from arrays', () => { + const input = [ + { id: 1, _links: { self: '/1' }, title: { raw: 'A', rendered: '

A

' } }, + { id: 2, _links: { self: '/2' }, title: { raw: 'B', rendered: '

B

' } }, + ]; + const { data } = stripNoisyFields( input ); + expect( data ).toEqual( [ + { id: 1, title: { raw: 'A' } }, + { id: 2, title: { raw: 'B' } }, + ] ); + } ); + + it( 'passes through primitives unchanged', () => { + expect( stripNoisyFields( 42 ).data ).toBe( 42 ); + expect( stripNoisyFields( 'hello' ).data ).toBe( 'hello' ); + expect( stripNoisyFields( null ).data ).toBe( null ); + } ); +} ); diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 06d703620d..7dc344a76b 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -57,6 +57,7 @@ export interface SiteInfo { remote?: boolean; url?: string; wpcomSiteId?: number; + planSlug?: string; } const DEFAULT_COLLAPSE_THRESHOLD_LINES = 5; @@ -829,6 +830,7 @@ export class AiChatUI { remote: true, url: site.url, wpcomSiteId: site.id, + planSlug: site.planSlug, } ) ); this.sitePickerRemoteLoading = false; this.rebuildSitePickerList(); diff --git a/apps/cli/ai/wpcom-tools.ts b/apps/cli/ai/wpcom-tools.ts index 327a4a0eb2..757dd150f4 100644 --- a/apps/cli/ai/wpcom-tools.ts +++ b/apps/cli/ai/wpcom-tools.ts @@ -26,6 +26,53 @@ function getErrorMessage( error: unknown ): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any type ApiResponse = any; +export { stripNoisyFields }; + +// Fields that bloat API responses without being useful to the agent. +const NOISY_FIELDS = new Set( [ + '_links', + '_embedded', + 'guid', + 'ping_status', + 'comment_status', + 'generated_slug', + 'permalink_template', +] ); + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function stripNoisyFields( + data: any, + removed = new Set< string >() +): { data: any; removed: Set< string > } { + return { data: doStrip( data, removed ), removed }; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function doStrip( data: any, removed: Set< string > ): any { + if ( Array.isArray( data ) ) { + return data.map( ( item ) => doStrip( item, removed ) ); + } + if ( data !== null && typeof data === 'object' ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const out: Record< string, any > = {}; + for ( const [ key, value ] of Object.entries( data ) ) { + if ( NOISY_FIELDS.has( key ) ) { + removed.add( key ); + continue; + } + // Drop `rendered` when `raw` is present in the same object + if ( key === 'rendered' && 'raw' in data ) { + removed.add( 'rendered (raw exists)' ); + continue; + } + out[ key ] = doStrip( value, removed ); + } + return out; + } + return data; +} + /** * Creates a generic WP.com REST API tool for managing a remote WordPress.com site. * Instead of hardcoding individual endpoints, this provides a single flexible tool @@ -41,7 +88,9 @@ export function createWpcomToolDefinitions( token: string, siteId: number ) { 'Defaults to the WordPress REST API (wp/v2). Use this to manage posts, pages, templates, template parts, ' + 'media, plugins, themes, settings, and any other site resource. ' + 'The path is relative to /sites/{siteId}/ — for example, pass "/posts" to call /wp/v2/sites/{siteId}/posts. ' + - 'For non-site endpoints, start the path with "!" (e.g., "!/me") to use an absolute path.', + 'For non-site endpoints, start the path with "!" (e.g., "!/me") to use an absolute path. ' + + 'IMPORTANT: To avoid oversized responses, always use the "fields" query parameter to request only the fields you need ' + + '(e.g., query: { "fields": "ID,title,slug,status" }). Use "per_page" to limit list results (default: 10, max: 100).', { method: z .enum( [ 'GET', 'POST', 'PUT', 'DELETE' ] ) @@ -106,7 +155,36 @@ export function createWpcomToolDefinitions( token: string, siteId: number ) { break; } - return textResult( JSON.stringify( result, null, 2 ) ); + // The Claude Agent SDK limits MCP tool results to ~25K tokens + // (MAX_MCP_OUTPUT_TOKENS, default 25000). At ~3 chars/token + // for JSON, 50K chars ≈ 16.7K tokens. + const MAX_RESPONSE_CHARS = 50000; + const json = JSON.stringify( result ); + + if ( json.length <= MAX_RESPONSE_CHARS ) { + return textResult( json ); + } + + // Over limit — strip noisy fields and retry + const { data: cleaned, removed } = stripNoisyFields( result ); + const stripped = JSON.stringify( cleaned ); + const removedList = [ ...removed ].join( ', ' ); + const fieldsHint = + `\n\n[Stripped fields: ${ removedList }. ` + + 'Re-request with "fields" parameter if you need any of these, or reduce "per_page".]'; + + if ( stripped.length + fieldsHint.length <= MAX_RESPONSE_CHARS ) { + return textResult( stripped + fieldsHint ); + } + + // Still too large — truncate + const truncationNote = + `\n\n[Truncated — response was ${ stripped.length.toLocaleString() } chars. ` + + `Stripped fields: ${ removedList }. ` + + 'Use "fields" to request only needed fields (e.g., { "fields": "ID,title,slug" }), or reduce "per_page".]'; + return textResult( + stripped.slice( 0, MAX_RESPONSE_CHARS - truncationNote.length ) + truncationNote + ); } catch ( error ) { return errorResult( `WP.com API request failed (${ args.method } ${ args.path }): ${ getErrorMessage( diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index 042cf17c54..9438fd34f0 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -382,7 +382,8 @@ export async function runCommand( let enrichedPrompt = prompt; const site = ui.activeSite; if ( site?.remote && site?.url ) { - enrichedPrompt = `[Active site: "${ site.name }" (ID: ${ site.wpcomSiteId }) at ${ site.url } (WordPress.com)]\n\n${ prompt }`; + const planLabel = site.planSlug ? `, plan: ${ site.planSlug }` : ''; + enrichedPrompt = `[Active site: "${ site.name }" (ID: ${ site.wpcomSiteId }) at ${ site.url } (WordPress.com${ planLabel })]\n\n${ prompt }`; } else if ( site ) { enrichedPrompt = `[Active site: "${ site.name }" at ${ site.path }${ site.running ? ' (running)' : ' (stopped)' diff --git a/apps/cli/lib/api.ts b/apps/cli/lib/api.ts index aede88ba1c..ce12aff7fe 100644 --- a/apps/cli/lib/api.ts +++ b/apps/cli/lib/api.ts @@ -183,6 +183,7 @@ export interface WpComSiteInfo { id: number; name: string; url: string; + planSlug?: string; } const wpComSitesResponseSchema = z.object( { @@ -192,6 +193,11 @@ const wpComSitesResponseSchema = z.object( { name: z.string(), URL: z.string(), is_deleted: z.boolean().optional(), + plan: z + .object( { + product_slug: z.string(), + } ) + .optional(), } ) ), } ); @@ -205,7 +211,7 @@ export async function getWpComSites( token: string ): Promise< WpComSiteInfo[] > path: '/me/sites', }, { - fields: 'ID,name,URL,is_deleted', + fields: 'ID,name,URL,is_deleted,plan', filter: 'atomic,wpcom', site_activity: 'active', } @@ -217,6 +223,7 @@ export async function getWpComSites( token: string ): Promise< WpComSiteInfo[] > id: site.ID, name: site.name, url: site.URL, + planSlug: site.plan?.product_slug, } ) ); } catch ( error ) { if ( error instanceof z.ZodError ) {