diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 1c03d86c35..ae92cdd264 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -44,6 +44,7 @@ import { type TSchema } from 'typebox'; import { createBlockSettingsNotes } from 'cli/ai/block-settings'; import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; +import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { buildSystemPrompt, type ToolPromptContribution } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; import { createAskUserQuestionTool } from 'cli/ai/tools/ask-user-question'; @@ -326,9 +327,10 @@ async function createStudioAgentSession( const model = buildModel( config.model, family, creds ); const chatArtifactsEnabled = typeof process.send === 'function'; const visionEnabled = aiModelSupportsImages( config.model ); - const [ userInstructions, imageGenerationEnabled ] = await Promise.all( [ + const [ userInstructions, imageGenerationEnabled, activeSiteFacts ] = await Promise.all( [ readGlobalInstructions(), isImageGenerationAvailable(), + readActiveSiteFacts( config.activeSite ), ] ); const tools = buildAgentTools( @@ -341,6 +343,7 @@ async function createStudioAgentSession( userInstructions, tools: tools.map( toolPromptContribution ), activeSite: config.activeSite, + activeSiteFacts, } ); const pendingWork = getPendingWork( config.session ); diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts new file mode 100644 index 0000000000..569e6bf3c8 --- /dev/null +++ b/apps/cli/ai/site-facts.ts @@ -0,0 +1,186 @@ +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { getWordPressVersionFromInstallation } from 'cli/lib/dependency-management/wordpress'; +import type { SiteInfo } from 'cli/ai/types'; + +// A block theme's templates, template parts, and theme.json are only what +// WordPress renders until someone saves a copy in the Site Editor: from then +// on WordPress renders the saved copy (a wp_template or wp_template_part post) +// or applies the saved Global Styles (a wp_global_styles post) over theme.json. +// The copies are read straight from the site's SQLite database, for the +// active site's facts. + +type SavedPostType = 'wp_template' | 'wp_template_part' | 'wp_global_styles'; + +export interface SavedCopy { + postType: SavedPostType; + id: number; + // The template or part's slug (the file name without .html). + slug: string; + // For a template or part: whether the theme (or its parent) has a file for it. + hasThemeFile?: boolean; + // For Global Styles: the areas they set, such as `color` or `14 block styles`. + sets?: string[]; +} + +// The theme folders WordPress reads a template or part's file from. +const THEME_FILE_DIRS: Record< 'wp_template' | 'wp_template_part', string[] > = { + wp_template: [ 'templates', 'block-templates' ], + wp_template_part: [ 'parts', 'block-template-parts' ], +}; + +function tablePrefix( root: string ): string { + try { + const config = readFileSync( path.join( root, 'wp-config.php' ), 'utf8' ); + return config.match( /\$table_prefix\s*=\s*['"]([A-Za-z0-9_]+)['"]/ )?.[ 1 ] ?? 'wp_'; + } catch { + return 'wp_'; + } +} + +// The areas saved Global Styles set: each style and setting they hold, with +// per-block styles counted. +function styleAreas( content: string ): string[] { + let data: { styles?: Record< string, unknown >; settings?: Record< string, unknown > }; + try { + data = JSON.parse( content ); + } catch { + return []; + } + const { blocks, ...styles } = data.styles ?? {}; + const blockCount = blocks && typeof blocks === 'object' ? Object.keys( blocks ).length : 0; + return [ + ...Object.keys( styles ), + ...( blockCount ? [ `${ blockCount } block style${ blockCount === 1 ? '' : 's' }` ] : [] ), + ...Object.keys( data.settings ?? {} ).map( ( key ) => `${ key } settings` ), + ]; +} + +/** + * The copies saved in the Site Editor for the active theme of the WordPress + * install at `root`. Global Styles that set nothing are left out. Never + * throws: a site without a readable database has none. + */ +export async function readSavedCopies( root: string ): Promise< SavedCopy[] > { + const dbPath = path.join( root, 'wp-content', 'database', '.ht.sqlite' ); + if ( ! existsSync( dbPath ) ) { + return []; + } + try { + const { DatabaseSync } = await import( 'node:sqlite' ); + const db = new DatabaseSync( dbPath, { readOnly: true } ); + try { + const prefix = tablePrefix( root ); + const option = ( name: string ) => + ( + db + .prepare( `SELECT option_value AS value FROM ${ prefix }options WHERE option_name = ?` ) + .get( name ) as { value: string } | undefined + )?.value; + const stylesheet = option( 'stylesheet' ); + if ( ! stylesheet ) return []; + const themeDirs = [ stylesheet, option( 'template' ) ?? stylesheet ].map( ( theme ) => + path.join( root, 'wp-content', 'themes', theme ) + ); + // The latest published post per type and slug, for the theme its + // wp_theme term names. + const rows = db + .prepare( + `SELECT p.ID AS id, p.post_type AS postType, p.post_name AS slug, p.post_content AS content + FROM ${ prefix }posts p + JOIN ${ prefix }term_relationships tr ON tr.object_id = p.ID + JOIN ${ prefix }term_taxonomy tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'wp_theme' + JOIN ${ prefix }terms t ON t.term_id = tt.term_id + WHERE p.post_type IN ( 'wp_template', 'wp_template_part', 'wp_global_styles' ) + AND p.post_status = 'publish' AND t.name = ? + ORDER BY p.post_type, p.post_name, p.post_date DESC, p.ID DESC` + ) + .all( stylesheet ) as unknown as Array< SavedCopy & { content: string } >; + const copies: SavedCopy[] = []; + const seen = new Set< string >(); + for ( const { content, ...row } of rows ) { + const key = + row.postType === 'wp_global_styles' ? row.postType : `${ row.postType }/${ row.slug }`; + if ( seen.has( key ) ) continue; + seen.add( key ); + if ( row.postType === 'wp_global_styles' ) { + const sets = styleAreas( content ); + if ( sets.length ) copies.push( { ...row, sets } ); + continue; + } + const hasThemeFile = themeDirs.some( ( themeDir ) => + THEME_FILE_DIRS[ row.postType as 'wp_template' | 'wp_template_part' ].some( ( dir ) => + existsSync( path.join( themeDir, dir, `${ row.slug }.html` ) ) + ) + ); + copies.push( { ...row, hasThemeFile } ); + } + return copies; + } finally { + db.close(); + } + } catch { + return []; + } +} + +// "templates index (post 8), single (9); template parts header (5)" +function listByType( copies: SavedCopy[] ): string { + const groups = ( + [ + [ 'wp_template', 'template', 'templates' ], + [ 'wp_template_part', 'template part', 'template parts' ], + ] as const + ) + .map( ( [ postType, one, many ] ) => { + const ofType = copies.filter( ( copy ) => copy.postType === postType ); + const items = ofType.map( + ( copy, i ) => `${ copy.slug } (${ i === 0 ? 'post ' : '' }${ copy.id })` + ); + return ofType.length ? `${ ofType.length === 1 ? one : many } ${ items.join( ', ' ) }` : ''; + } ) + .filter( Boolean ); + return groups.join( '; ' ); +} + +/** One line for the active site's facts, or undefined when nothing is saved. */ +export function formatSavedCopies( copies: SavedCopy[] ): string | undefined { + const overriding = listByType( copies.filter( ( copy ) => copy.hasThemeFile ) ); + const editorOnly = listByType( copies.filter( ( copy ) => copy.hasThemeFile === false ) ); + const styles = copies.find( ( copy ) => copy.postType === 'wp_global_styles' ); + const sentences = [ + overriding && + `Saved in the Site Editor, which WordPress uses instead of the theme's files: ${ overriding }.`, + editorOnly && `Only in the Site Editor: ${ editorOnly }.`, + styles && + `Global Styles (post ${ styles.id }), applied over theme.json, set ${ joinList( + styles.sets ?? [] + ) }.`, + ].filter( Boolean ); + return sentences.length ? sentences.join( ' ' ) : undefined; +} + +function joinList( items: string[] ): string { + return items.length < 3 + ? items.join( ' and ' ) + : `${ items.slice( 0, -1 ).join( ', ' ) }, and ${ items[ items.length - 1 ] }`; +} + +/** + * Facts about a local site read from its own files and database, one line + * each, for the Active site section: the WordPress version it runs and the + * copies saved in the Site Editor. + */ +export async function readActiveSiteFacts( site?: SiteInfo | null ): Promise< string[] > { + if ( ! site || site.remote ) { + return []; + } + const [ version, savedCopies ] = await Promise.all( [ + getWordPressVersionFromInstallation( site.path ), + readSavedCopies( site.path ), + ] ); + return [ + version ? `WordPress ${ version }.` : undefined, + formatSavedCopies( savedCopies ), + ].filter( ( fact ): fact is string => Boolean( fact ) ); +} diff --git a/apps/cli/ai/skills/edit-manage-site/SKILL.md b/apps/cli/ai/skills/edit-manage-site/SKILL.md index be57178b50..f59027f7bc 100644 --- a/apps/cli/ai/skills/edit-manage-site/SKILL.md +++ b/apps/cli/ai/skills/edit-manage-site/SKILL.md @@ -16,7 +16,6 @@ Work on the one site the request is about, and start it with site_start if it is For any change to content, templates, or the theme, load the `visual-design` skill first, along with the other skills the change calls for (Skill routing names them). Follow the site's existing look — its DESIGN.md and active theme — rather than redesigning it: the catalogs and design picks in `visual-design` are for builds. Then: -- Before editing an existing theme's template or part, check for a copy saved in the Site Editor (`post list --post_type=wp_template,wp_template_part`): WordPress renders that saved copy instead of the file, so if one exists, make the change in the saved copy too, keep the user's other edits, and tell the user. - Change an installed third-party theme only through a child theme, following the `block-content` skill's theme-file rules. - Validate every template, part, and piece of content you write with validate_blocks before it reaches the site. diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index b1cabba6ab..0b98023462 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -24,6 +24,9 @@ export interface BuildSystemPromptOptions { // The site this turn works on. The prompt is rebuilt every turn, so this // always reflects the current site, also after compaction. activeSite?: SiteInfo | null; + // Facts about the active site read from its own data, one line each (for + // example the copies saved in the Site Editor). + activeSiteFacts?: string[]; } export function buildSystemPrompt( options: BuildSystemPromptOptions = {} ): string { @@ -44,20 +47,21 @@ ${ options.external ? '' : `${ WORKING_CADENCE }\n\n` }${ renderToolSections( options.tools ?? [] ) } -${ SKILL_ROUTING }${ buildActiveSiteSection( options.activeSite ) }${ buildUserInstructionsSection( - options.userInstructions - ) } +${ SKILL_ROUTING }${ buildActiveSiteSection( + options.activeSite, + options.activeSiteFacts + ) }${ buildUserInstructionsSection( options.userInstructions ) } `; } -function buildActiveSiteSection( site?: SiteInfo | null ): string { +function buildActiveSiteSection( site?: SiteInfo | null, facts: string[] = [] ): string { if ( ! site ) { return ''; } // A WordPress.com site is live and changes only through wpcom_request, so // its runbook comes with it rather than waiting for the agent to load it. const runbook = site.remote ? findSkill( 'edit-manage-remote-site' )?.body : undefined; - return `\n\n## Active site\n\n${ formatActiveSite( site ) }${ + return `\n\n## Active site\n\n${ [ formatActiveSite( site ), ...facts ].join( '\n' ) }${ runbook ? `\n\n${ runbook }` : '' }`; } diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts new file mode 100644 index 0000000000..02666298dc --- /dev/null +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -0,0 +1,133 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import { DatabaseSync } from 'node:sqlite'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { formatSavedCopies, readActiveSiteFacts, readSavedCopies } from '../site-facts'; + +// A WordPress root with the tables a saved Site Editor copy lives in. +function makeSite( prefix = 'wp_' ) { + const root = mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-' ) ); + mkdirSync( path.join( root, 'wp-includes' ) ); + mkdirSync( path.join( root, 'wp-content', 'database' ), { recursive: true } ); + writeFileSync( path.join( root, 'wp-config.php' ), ` { + mkdirSync( path.dirname( path.join( root, 'wp-content', 'themes', file ) ), { + recursive: true, + } ); + writeFileSync( path.join( root, 'wp-content', 'themes', file ), '' ); + }; + themeFile( 'bakery/parts/header.html' ); + themeFile( 'bakery-parent/templates/index.html' ); + themeFile( 'bakery-parent/templates/single.html' ); + const db = new DatabaseSync( path.join( root, 'wp-content', 'database', '.ht.sqlite' ) ); + db.exec( ` + CREATE TABLE ${ prefix }posts ( ID INTEGER PRIMARY KEY, post_type TEXT, post_status TEXT, post_name TEXT, post_content TEXT, post_date TEXT ); + CREATE TABLE ${ prefix }terms ( term_id INTEGER PRIMARY KEY, name TEXT ); + CREATE TABLE ${ prefix }term_taxonomy ( term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER, taxonomy TEXT ); + CREATE TABLE ${ prefix }term_relationships ( object_id INTEGER, term_taxonomy_id INTEGER ); + CREATE TABLE ${ prefix }options ( option_name TEXT, option_value TEXT ); + INSERT INTO ${ prefix }options VALUES ( 'stylesheet', 'bakery' ), ( 'template', 'bakery-parent' ); + INSERT INTO ${ prefix }terms VALUES ( 1, 'bakery' ), ( 2, 'other-theme' ); + INSERT INTO ${ prefix }term_taxonomy VALUES ( 1, 1, 'wp_theme' ), ( 2, 2, 'wp_theme' ); + ` ); + let id = 10; + const save = ( + postType: string, + name: string, + content: string, + theme = 1, + status = 'publish' + ) => { + id += 1; + db.prepare( `INSERT INTO ${ prefix }posts VALUES ( ?, ?, ?, ?, ?, '2026-10-22' )` ).run( + id, + postType, + status, + name, + content + ); + db.prepare( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )` ).run( id, theme ); + return id; + }; + return { root, save, close: () => db.close() }; +} + +describe( 'readSavedCopies', () => { + it( "states the active theme's saved copies, grouped, and which replace a theme file", async () => { + const site = makeSite(); + site.save( 'wp_template_part', 'header', '' ); + site.save( 'wp_template', 'index', '' ); + site.save( 'wp_template', 'single', '' ); + site.save( 'wp_template', 'page-about', '' ); + site.save( 'wp_template_part', 'promo', '' ); + site.save( + 'wp_global_styles', + 'wp-global-styles-bakery', + JSON.stringify( { + version: 3, + styles: { + color: { background: '#fff' }, + typography: { lineHeight: '1.7' }, + blocks: { 'core/button': {}, 'core/quote': {} }, + }, + settings: { color: { palette: {} } }, + } ) + ); + site.close(); + + expect( formatSavedCopies( await readSavedCopies( site.root ) ) ).toBe( + "Saved in the Site Editor, which WordPress uses instead of the theme's files: templates index (post 12), single (13); template part header (post 11). " + + 'Only in the Site Editor: template page-about (post 14); template part promo (post 15). ' + + 'Global Styles (post 16), applied over theme.json, set color, typography, 2 block styles, and color settings.' + ); + } ); + + it( 'takes the latest published copy per slug, for the active theme only', async () => { + const site = makeSite( 'wpx_' ); + site.save( 'wp_template', 'index', '', 2 ); + site.save( 'wp_template', 'home', '', 1, 'draft' ); + site.save( 'wp_template_part', 'header', '' ); + site.save( 'wp_template_part', 'header', '' ); + site.close(); + + expect( await readSavedCopies( site.root ) ).toEqual( [ + { postType: 'wp_template_part', id: 14, slug: 'header', hasThemeFile: true }, + ] ); + } ); + + it( 'has nothing for empty Global Styles or a site without a database', async () => { + const site = makeSite(); + site.save( 'wp_global_styles', 'wp-global-styles-bakery', '{"version":3,"styles":{}}' ); + site.close(); + expect( await readSavedCopies( site.root ) ).toEqual( [] ); + expect( + await readSavedCopies( mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-bare-' ) ) ) + ).toEqual( [] ); + expect( formatSavedCopies( [] ) ).toBeUndefined(); + } ); +} ); + +describe( 'readActiveSiteFacts', () => { + it( 'states the WordPress a local site runs and where its block types are declared', async () => { + const sitePath = mkdtempSync( path.join( os.tmpdir(), 'site-facts-' ) ); + mkdirSync( path.join( sitePath, 'wp-includes' ) ); + writeFileSync( + path.join( sitePath, 'wp-includes', 'version.php' ), + " { + expect( + await readActiveSiteFacts( { name: 'Live', path: '', running: false, remote: true } ) + ).toEqual( [] ); + expect( await readActiveSiteFacts( null ) ).toEqual( [] ); + } ); +} ); diff --git a/apps/cli/ai/tests/system-prompt.test.ts b/apps/cli/ai/tests/system-prompt.test.ts index 435d66d769..fc45dea741 100644 --- a/apps/cli/ai/tests/system-prompt.test.ts +++ b/apps/cli/ai/tests/system-prompt.test.ts @@ -133,6 +133,17 @@ describe( 'buildSystemPrompt', () => { expect( buildSystemPrompt( {} ) ).not.toContain( '## Active site' ); } ); + it( "lists the active site's facts under it, one per line", () => { + const prompt = buildSystemPrompt( { + activeSite: { name: 'My Site', path: '/sites/my-site', running: true }, + activeSiteFacts: [ 'Saved in the Site Editor: template part `header` (post 5).' ], + } ); + + expect( prompt ).toContain( + '"My Site" at /sites/my-site (running)\nSaved in the Site Editor: template part `header` (post 5).' + ); + } ); + it( 'states a WordPress.com active site with its ID and URL', () => { const prompt = buildSystemPrompt( { activeSite: {