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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/cli/ai/eval-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@
*/

import { mkdirSync, writeFileSync, writeSync as fsWriteSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SessionManager } from '@earendil-works/pi-coding-agent';
import {
readGlobalInstructionsFile,
writeGlobalInstructions,
} from '@studio/common/ai/global-instructions';
import { DEFAULT_MODEL, isAiModelId, type AiModelId } from '@studio/common/ai/models';
import { findLastAssistant } from '@studio/common/ai/session-events';
import {
addConnectedWpcomSite,
removeConnectedWpcomSite,
} from '@studio/common/lib/connected-sites';
import { getGlobalInstructionsPath } from '@studio/common/lib/well-known-paths';
import { snapshotSchema } from '@studio/common/types/snapshot';
import { syncSiteSchema, type SyncSite } from '@studio/common/types/sync';
import { z } from 'zod';
Expand Down Expand Up @@ -52,6 +58,7 @@ const evalSeedSchema = z.object( {
.optional(),
connectedWpcomSites: z.array( syncSiteSchema ).optional(),
snapshots: z.array( snapshotSchema ).optional(),
globalInstructions: z.string().optional(),
} );
type EvalSeed = z.infer< typeof evalSeedSchema >;

Expand All @@ -66,9 +73,21 @@ interface EvalRunnerInput {
* Writes the requested fixtures into cli.json (local site + snapshots) and
* shared.json (connected WordPress.com sites). Returns a cleanup function that
* removes exactly what was added, so reruns start from a clean slate.
* `globalInstructions` is the exception: it overwrites the user's real
* instructions file, so cleanup restores the prior content instead.
*/
async function seedFixtures( seed: EvalSeed ): Promise< () => Promise< void > > {
const { localSite, connectedWpcomSites = [], snapshots = [] } = seed;
const { localSite, connectedWpcomSites = [], snapshots = [], globalInstructions } = seed;

let restoreInstructions: ( () => Promise< void > ) | null = null;
if ( typeof globalInstructions === 'string' ) {
const prior = await readGlobalInstructionsFile();
restoreInstructions =
prior === null
? () => rm( getGlobalInstructionsPath(), { force: true } )
: () => writeGlobalInstructions( prior );
await writeGlobalInstructions( globalInstructions );
}

if ( localSite || snapshots.length > 0 ) {
try {
Expand Down Expand Up @@ -100,6 +119,7 @@ async function seedFixtures( seed: EvalSeed ): Promise< () => Promise< void > >
}

return async () => {
await restoreInstructions?.().catch( () => undefined );
for ( const site of seededConnections ) {
await removeConnectedWpcomSite( site.localSiteId, site.id ).catch( () => undefined );
}
Expand Down
9 changes: 8 additions & 1 deletion apps/cli/ai/runtimes/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type SessionManager,
type ToolDefinition,
} from '@earendil-works/pi-coding-agent';
import { readGlobalInstructions } from '@studio/common/ai/global-instructions';
import {
DEFAULT_MODEL,
getAiModelFamily,
Expand Down Expand Up @@ -293,6 +294,10 @@ async function createStudioAgentSession(
const isRemoteSite = Boolean( config.activeSite?.remote && config.activeSite?.wpcomSiteId );
const remoteSession = config.env.STUDIO_REMOTE_SESSION === '1';
const chatArtifactsEnabled = typeof process.send === 'function';
const [ userInstructions, runtime ] = await Promise.all( [
readGlobalInstructions(),
isRemoteSite ? undefined : resolveActiveSiteRuntime( config.activeSite ),
] );

const systemPrompt = buildSystemPrompt(
isRemoteSite
Expand All @@ -303,11 +308,13 @@ async function createStudioAgentSession(
id: config.activeSite!.wpcomSiteId!,
},
remoteSession,
userInstructions,
}
: {
chatArtifactsEnabled,
remoteSession,
runtime: await resolveActiveSiteRuntime( config.activeSite ),
runtime,
userInstructions,
}
);

Expand Down
28 changes: 26 additions & 2 deletions apps/cli/ai/system-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GLOBAL_INSTRUCTIONS_MAX_LENGTH } from '@studio/common/ai/global-instructions';
import {
getStudioPresentationRulesPrompt,
getStudioWidgetPromptManifest,
Expand All @@ -23,17 +24,20 @@ export interface BuildSystemPromptOptions {
// Runtime of the active local site. Playground (PHP WASM) needs extra WP-CLI
// constraints that the native PHP runtime does not. Defaults to native-php.
runtime?: SiteRuntime;
// The user's global instructions (~/.studio/knowledge/instructions.md).
userInstructions?: string;
}

export function buildSystemPrompt( options?: BuildSystemPromptOptions ): string {
const remoteSessionAddendum = options?.remoteSession ? `\n\n${ REMOTE_SESSION_GUIDANCE }` : '';
const userInstructionsSection = buildUserInstructionsSection( options?.userInstructions );

if ( options?.remoteSite ) {
return `${ buildRemoteIntro( options.remoteSite ) }

${ REMOTE_CONTENT_GUIDELINES }

${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }
${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }${ userInstructionsSection }
`;
}

Expand All @@ -43,10 +47,30 @@ ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }
runtime: options?.runtime,
} ) }

${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum }
${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum }${ userInstructionsSection }
`;
}

function buildUserInstructionsSection( userInstructions?: string ): string {
if ( ! userInstructions ) {
return '';
}
const instructions =
userInstructions.length > GLOBAL_INSTRUCTIONS_MAX_LENGTH
? `${ userInstructions.slice(
0,
GLOBAL_INSTRUCTIONS_MAX_LENGTH
) }\n\n[Note: the global instructions file exceeds the size limit and was truncated here. Let the user know they should shorten it in Studio settings.]`
: userInstructions;
Comment on lines +58 to +64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider adding a limit in the Settings text area or some kind of indicator.

return `

## User's global instructions

The user saved these standing instructions in Studio's settings. They apply to every conversation. Follow them unless they conflict with the guidance above or ask you to skip safety, plan, or validation requirements.

${ instructions }`;
}

function buildRemoteIntro( site: RemoteSiteContext ): string {
return `${ AGENT_IDENTITY } You manage WordPress.com sites using the WordPress.com REST API.

Expand Down
26 changes: 26 additions & 0 deletions apps/cli/ai/tests/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,32 @@ describe( 'buildSystemPrompt', () => {
expect( prompt ).not.toContain( 'Do not respond as though the user is looking at the capture' );
} );

it( 'appends the user global instructions for local and remote sessions', () => {
const variants = [ { chatArtifactsEnabled: true }, { remoteSite } ];
for ( const variant of variants ) {
const prompt = buildSystemPrompt( {
...variant,
userInstructions: 'Always answer in French.',
} );
expect( prompt ).toContain( "## User's global instructions" );
expect( prompt ).toContain( 'Always answer in French.' );
}
} );

it( 'omits the global instructions section when none are set', () => {
const prompts = [ buildSystemPrompt( {} ), buildSystemPrompt( { remoteSite } ) ];
for ( const prompt of prompts ) {
expect( prompt ).not.toContain( "## User's global instructions" );
}
} );

it( 'truncates oversized global instructions with a visible notice', () => {
const prompt = buildSystemPrompt( { userInstructions: 'a'.repeat( 20_000 ) } );

expect( prompt ).toContain( 'was truncated here' );
expect( prompt ).not.toContain( 'a'.repeat( 17_000 ) );
} );

it( 'omits the terminal screenshot caveat for remote-bridge sessions', () => {
// The Telegram user cannot open local file paths; delivery is covered
// by the remote-session share_screenshot guidance instead.
Expand Down
25 changes: 25 additions & 0 deletions apps/local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { createWriteStream, existsSync, mkdtempSync, rm } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import {
readGlobalInstructionsFile,
writeGlobalInstructions,
} from '@studio/common/ai/global-instructions';
import { isAiModelId } from '@studio/common/ai/models';
import { createAgentRunManager } from '@studio/common/ai/run-manager';
import {
Expand Down Expand Up @@ -423,6 +427,27 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
} )
);

// --- Agent instructions — the shared ~/.studio/knowledge/instructions.md --
api.get(
'/agent-instructions',
asyncHandler( async ( _req: Request, res: Response ) => {
res.json( { content: ( await readGlobalInstructionsFile() ) ?? '' } );
} )
);

api.post(
'/agent-instructions',
asyncHandler( async ( req: Request, res: Response ) => {
const content = req.body?.content;
if ( typeof content !== 'string' ) {
res.status( 400 ).json( { error: 'content required' } );
return;
}
await writeGlobalInstructions( content );
res.status( 204 ).end();
} )
);

// --- Sites — the local machine's real Studio sites, via the CLI -----------
api.get(
'/sites',
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export {

export {
getColorScheme,
getGlobalAgentInstructions,
getInstalledAppsAndTerminals,
getQuitSitesBehavior,
getUserEditor,
Expand All @@ -237,6 +238,7 @@ export {
getWapuuScore,
previewColorScheme,
saveColorScheme,
saveGlobalAgentInstructions,
saveQuitSitesBehavior,
saveUserEditor,
saveUserLocale,
Expand Down
104 changes: 104 additions & 0 deletions apps/studio/src/modules/user-settings/components/studio-code-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { GLOBAL_INSTRUCTIONS_MAX_LENGTH } from '@studio/common/ai/global-instructions';
import { getErrorMessage } from '@studio/common/lib/error-formatting';
import { TextareaControl } from '@wordpress/components';
import { useI18n } from '@wordpress/react-i18n';
import { useEffect, useState } from 'react';
import Button from 'src/components/button';
import { getIpcApi } from 'src/lib/get-ipc-api';

export function StudioCodeTab() {
const { __ } = useI18n();
const [ content, setContent ] = useState< string | null >( null );
const [ savedContent, setSavedContent ] = useState( '' );
const [ isSaving, setIsSaving ] = useState( false );
const [ justSaved, setJustSaved ] = useState( false );
const [ error, setError ] = useState< string | null >( null );

useEffect( () => {
let cancelled = false;
getIpcApi()
.getGlobalAgentInstructions()
.then( ( instructions ) => {
if ( ! cancelled ) {
setContent( instructions );
setSavedContent( instructions );
}
} )
.catch( ( err ) => {
if ( ! cancelled ) {
setError( getErrorMessage( err ) ?? String( err ) );
}
} );
return () => {
cancelled = true;
};
}, [] );

useEffect( () => {
if ( ! justSaved ) {
return;
}
const timer = setTimeout( () => setJustSaved( false ), 2500 );
return () => clearTimeout( timer );
}, [ justSaved ] );

const handleSave = async () => {
if ( content === null ) {
return;
}
setIsSaving( true );
setError( null );
try {
await getIpcApi().saveGlobalAgentInstructions( content );
setSavedContent( content );
setJustSaved( true );
} catch ( err ) {
setError( getErrorMessage( err ) ?? String( err ) );
} finally {
setIsSaving( false );
}
};

const isDirty = content !== null && content !== savedContent;
const showCounter = content !== null && content.length >= GLOBAL_INSTRUCTIONS_MAX_LENGTH * 0.8;
const buttonLabel = isSaving
? __( 'Saving…' )
: justSaved && ! isDirty
? __( 'Saved' )
: __( 'Save instructions' );

return (
<div className="flex flex-col gap-4 pb-2">
{ error && <div className="text-sm text-frame-error">{ error }</div> }

<TextareaControl
__nextHasNoMarginBottom
label={ __( 'Instructions' ) }
help={ __(
'Global instructions for the Studio Code agent. They are included in every new conversation, across all sites.'
) }
rows={ 12 }
maxLength={ GLOBAL_INSTRUCTIONS_MAX_LENGTH }
value={ content ?? '' }
onChange={ setContent }
disabled={ content === null }
placeholder={ __( 'e.g. Always answer in French. My sites are for restaurants.' ) }
/>

<div className="flex items-center justify-end gap-3">
{ showCounter && (
<span className="text-xs text-frame-text-secondary">
{ `${ content.length.toLocaleString() } / ${ GLOBAL_INSTRUCTIONS_MAX_LENGTH.toLocaleString() }` }
</span>
) }
<Button
variant="primary"
onClick={ handleSave }
disabled={ content === null || isSaving || ! isDirty }
>
{ buttonLabel }
</Button>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// To run tests, execute `npm run test -- src/modules/user-settings/components/tests/user-settings.test.tsx` from the root directory
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { vi } from 'vitest';
Expand Down Expand Up @@ -48,6 +48,8 @@ const mockIpcApi = {
saveColorScheme: vi.fn().mockResolvedValue( undefined ),
getQuitSitesBehavior: vi.fn().mockResolvedValue( undefined ),
saveQuitSitesBehavior: vi.fn().mockResolvedValue( undefined ),
getGlobalAgentInstructions: vi.fn().mockResolvedValue( '' ),
saveGlobalAgentInstructions: vi.fn().mockResolvedValue( undefined ),
};

vi.mock( 'src/lib/get-ipc-api', () => ( {
Expand Down Expand Up @@ -161,12 +163,25 @@ describe( 'UserSettings', () => {
expect( screen.getByText( 'Account' ) ).toHaveAttribute( 'aria-selected', 'true' );
expect( screen.getByText( 'Log out' ) ).toBeInTheDocument();
expect( screen.getByText( 'Preview sites' ) ).toBeInTheDocument();
expect( screen.getByText( 'Studio Code' ) ).toBeInTheDocument();
// Scoped to the panel: "Studio Code" also names a settings tab.
expect(
within( screen.getByRole( 'tabpanel' ) ).getByText( 'Studio Code' )
).toBeInTheDocument();
expect(
screen.getByText( 'Studio Code limits are temporarily unavailable.' )
).toBeInTheDocument();
expect( screen.queryByText( /monthly prompts used/ ) ).not.toBeInTheDocument();
} );

await user.click( screen.getByRole( 'tab', { name: 'Studio Code' } ) );

await waitFor( () => {
expect( screen.getByRole( 'tab', { name: 'Studio Code' } ) ).toHaveAttribute(
'aria-selected',
'true'
);
expect( screen.getByLabelText( 'Instructions' ) ).toBeInTheDocument();
} );
} );
} );

Expand Down
Loading