Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/cli/ai/block-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { readFileSync } from 'fs';
import path from 'path';

// Matches the opening comment of a block in serialized markup, e.g.
// `<!-- wp:navigation /-->` or `<!-- wp:core/group {...} -->`. Closing
// comments (`<!-- /wp:group -->`) do not match.
const BLOCK_COMMENT = /<!--\s+wp:([a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)?)[\s/]/g;

type BlockAttribute = {
type?: string | string[];
enum?: unknown[];
default?: unknown;
source?: string;
};

function coreSlug( blockName: string ): string | undefined {
if ( ! blockName.includes( '/' ) ) {
return blockName;
}
return blockName.startsWith( 'core/' ) ? blockName.slice( 'core/'.length ) : undefined;
}

function describeAttribute( name: string, attribute: BlockAttribute ): string {
const shape = attribute.enum
? attribute.enum.map( ( value ) => JSON.stringify( value ) ).join( '|' )
: [ attribute.type ?? 'any' ].flat().join( '|' );
const fallback =
attribute.default === undefined ? '' : `, default ${ JSON.stringify( attribute.default ) }`;
return `${ name } (${ shape }${ fallback })`;
}

// The settings a core block type declares in its block.json: the attributes
// stored in the block comment. Attributes read from the block's HTML
// (`source`) are its content, and `__unstable`/`__experimental` ones are
// internal, so both are left out.
export function describeCoreBlockSettings( blocksDir: string, slug: string ): string | undefined {
const file = path.join( blocksDir, slug, 'block.json' );
let attributes: Record< string, BlockAttribute >;
try {
attributes = JSON.parse( readFileSync( file, 'utf8' ) ).attributes ?? {};
} catch {
return undefined;
}
const settings = Object.entries( attributes )
.filter( ( [ name, attribute ] ) => ! attribute.source && ! name.startsWith( '__' ) )
.map( ( [ name, attribute ] ) => describeAttribute( name, attribute ) );
const where = file.split( path.sep ).join( '/' );
return settings.length
? `core/${ slug } settings, declared in ${ where }: ${ settings.join( ', ' ) }.`
: undefined;
}

// Returns a function that, given a tool result's text, describes the settings
// of each core block type that appears in it. Each block type is described
// once per tracker.
export function createBlockSettingsNotes( blocksDir: string ): ( text: string ) => string {
const described = new Set< string >();
return ( text ) => {
const notes: string[] = [];
for ( const match of text.matchAll( BLOCK_COMMENT ) ) {
const slug = coreSlug( match[ 1 ] );
if ( ! slug || described.has( slug ) ) {
continue;
}
described.add( slug );
const note = describeCoreBlockSettings( blocksDir, slug );
if ( note ) {
notes.push( note );
}
}
return notes.join( '\n' );
};
}
23 changes: 18 additions & 5 deletions apps/cli/ai/runtimes/pi/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'fs';
import path from 'path';
import { type AgentTool } from '@earendil-works/pi-agent-core';
import {
type Credential,
Expand Down Expand Up @@ -40,6 +41,7 @@ import {
import { findLastAssistant, isEmptyStop } from '@studio/common/ai/session-events';
import { getAiTracksIdentity } from '@studio/common/ai/tracks-identity';
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 { buildSystemPrompt, type ToolPromptContribution } from 'cli/ai/system-prompt';
Expand Down Expand Up @@ -342,8 +344,13 @@ async function createStudioAgentSession(
} );

const pendingWork = getPendingWork( config.session );
const site = config.activeSite;
const blockSettingsNotes =
site && ! site.remote
? createBlockSettingsNotes( path.join( site.path, 'wp-includes', 'blocks' ) )
: undefined;
const toolDefinitions = tools.map( ( tool ) =>
toToolDefinition( tool, payloadGuardState, pendingWork )
toToolDefinition( tool, payloadGuardState, pendingWork, blockSettingsNotes )
);
const modelRuntime = await createModelRuntime( model, family, creds );
const settingsManager = createSettingsManager( config.env );
Expand Down Expand Up @@ -619,7 +626,8 @@ function toolPromptContribution( tool: AgentToolAny ): ToolPromptContribution {
function toToolDefinition(
tool: AgentToolAny,
payloadGuardState: StudioToolPayloadGuardState,
pendingWork: PendingWork
pendingWork: PendingWork,
blockSettingsNotes?: ( text: string ) => string
): ToolDefinition {
return {
name: tool.name,
Expand Down Expand Up @@ -651,9 +659,14 @@ function toToolDefinition(
pendingWork.add( details.pending );
delete details.pending;
}
const reports = pendingWork.takeReports();
return reports
? { ...result, content: [ ...result.content, { type: 'text', text: reports } ] }
const resultText = result.content
.map( ( part ) => ( part.type === 'text' ? part.text : '' ) )
.join( '\n' );
const notes = [ blockSettingsNotes?.( resultText ), pendingWork.takeReports() ]
.filter( Boolean )
.join( '\n' );
return notes
? { ...result, content: [ ...result.content, { type: 'text', text: notes } ] }
: result;
},
};
Expand Down
68 changes: 68 additions & 0 deletions apps/cli/ai/tests/block-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createBlockSettingsNotes } from 'cli/ai/block-settings';

const HEADER = `<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:site-title {"level":0} /-->
<!-- wp:navigation /-->
<!-- wp:acme/badge /-->
</div>
<!-- /wp:group -->`;

describe( 'createBlockSettingsNotes', () => {
let blocksDir: string;

function declare( slug: string, attributes: Record< string, unknown > ) {
mkdirSync( path.join( blocksDir, slug ), { recursive: true } );
writeFileSync(
path.join( blocksDir, slug, 'block.json' ),
JSON.stringify( { name: `core/${ slug }`, attributes } )
);
}

beforeEach( () => {
blocksDir = path.join( mkdtempSync( path.join( tmpdir(), 'block-settings-' ) ), 'blocks' );
declare( 'navigation', {
ref: { type: 'number' },
submenuVisibility: { type: 'string', enum: [ 'hover', 'click', 'always' ], default: 'hover' },
showSubmenuIcon: { type: 'boolean', default: true },
__unstableLocation: { type: 'string' },
} );
declare( 'site-title', { level: { type: 'number', default: 1 } } );
declare( 'group', { tagName: { type: 'string', default: 'div' } } );
declare( 'paragraph', { content: { type: 'rich-text', source: 'rich-text', selector: 'p' } } );
} );

afterEach( () => {
rmSync( path.dirname( blocksDir ), { recursive: true, force: true } );
} );

it( 'describes each core block type in the markup from its block.json', () => {
const notes = createBlockSettingsNotes( blocksDir )( HEADER ).split( '\n' );

const where = blocksDir.split( path.sep ).join( '/' );
expect( notes ).toEqual( [
`core/group settings, declared in ${ where }/group/block.json: tagName (string, default "div").`,
`core/site-title settings, declared in ${ where }/site-title/block.json: level (number, default 1).`,
`core/navigation settings, declared in ${ where }/navigation/block.json: ref (number), submenuVisibility ("hover"|"click"|"always", default "hover"), showSubmenuIcon (boolean, default true).`,
] );
} );

it( 'describes a block type once', () => {
const notes = createBlockSettingsNotes( blocksDir );

expect( notes( HEADER ) ).toContain( 'core/navigation' );
expect( notes( '<!-- wp:core/navigation {"ref":4} /-->' ) ).toBe( '' );
} );

it( 'leaves out content attributes, closing comments and undeclared blocks', () => {
const notes = createBlockSettingsNotes( blocksDir );

expect( notes( '<!-- wp:paragraph --><p>Hi</p><!-- /wp:paragraph -->' ) ).toBe( '' );
expect( notes( '<!-- /wp:group --><!-- wp:quote /-->' ) ).toBe( '' );
expect( notes( 'No blocks here.' ) ).toBe( '' );
} );
} );
Loading