Skip to content
5 changes: 4 additions & 1 deletion apps/cli/ai/runtimes/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -341,6 +343,7 @@ async function createStudioAgentSession(
userInstructions,
tools: tools.map( toolPromptContribution ),
activeSite: config.activeSite,
activeSiteFacts,
} );

const pendingWork = getPendingWork( config.session );
Expand Down
186 changes: 186 additions & 0 deletions apps/cli/ai/site-facts.ts
Original file line number Diff line number Diff line change
@@ -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 ) );
}
1 change: 0 additions & 1 deletion apps/cli/ai/skills/edit-manage-site/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 9 additions & 5 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }` : ''
}`;
}
Expand Down
133 changes: 133 additions & 0 deletions apps/cli/ai/tests/site-facts.test.ts
Original file line number Diff line number Diff line change
@@ -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' ), `<?php\n$table_prefix = '${ prefix }';\n` );
// bakery is a child theme: its own header part, and index and single
// templates from its parent.
const themeFile = ( file: string ) => {
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' ),
"<?php\n$wp_version = '7.1.3';\n"
);

const facts = await readActiveSiteFacts( { name: 'Site', path: sitePath, running: true } );

expect( facts ).toEqual( [ 'WordPress 7.1.3.' ] );
} );

it( 'has no facts for a WordPress.com site or no site', async () => {
expect(
await readActiveSiteFacts( { name: 'Live', path: '', running: false, remote: true } )
).toEqual( [] );
expect( await readActiveSiteFacts( null ) ).toEqual( [] );
} );
} );
Loading
Loading