Skip to content

Commit f76382c

Browse files
Riad Benguellaclaude
authored andcommitted
Add global Studio Code instructions config (~/.studio/knowledge/instructions.md)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 853afe7 commit f76382c

27 files changed

Lines changed: 547 additions & 11 deletions

File tree

‎apps/cli/ai/eval-runner.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@
77
*/
88

99
import { mkdirSync, writeFileSync, writeSync as fsWriteSync } from 'node:fs';
10+
import { rm } from 'node:fs/promises';
1011
import os from 'node:os';
1112
import path from 'node:path';
1213
import { SessionManager } from '@earendil-works/pi-coding-agent';
14+
import {
15+
readGlobalInstructionsFile,
16+
writeGlobalInstructions,
17+
} from '@studio/common/ai/global-instructions';
1318
import { DEFAULT_MODEL, isAiModelId, type AiModelId } from '@studio/common/ai/models';
1419
import { findLastAssistant } from '@studio/common/ai/session-events';
1520
import {
1621
addConnectedWpcomSite,
1722
removeConnectedWpcomSite,
1823
} from '@studio/common/lib/connected-sites';
24+
import { getGlobalInstructionsPath } from '@studio/common/lib/well-known-paths';
1925
import { snapshotSchema } from '@studio/common/types/snapshot';
2026
import { syncSiteSchema, type SyncSite } from '@studio/common/types/sync';
2127
import { z } from 'zod';
@@ -52,6 +58,7 @@ const evalSeedSchema = z.object( {
5258
.optional(),
5359
connectedWpcomSites: z.array( syncSiteSchema ).optional(),
5460
snapshots: z.array( snapshotSchema ).optional(),
61+
globalInstructions: z.string().optional(),
5562
} );
5663
type EvalSeed = z.infer< typeof evalSeedSchema >;
5764

@@ -66,9 +73,21 @@ interface EvalRunnerInput {
6673
* Writes the requested fixtures into cli.json (local site + snapshots) and
6774
* shared.json (connected WordPress.com sites). Returns a cleanup function that
6875
* removes exactly what was added, so reruns start from a clean slate.
76+
* `globalInstructions` is the exception: it overwrites the user's real
77+
* instructions file, so cleanup restores the prior content instead.
6978
*/
7079
async function seedFixtures( seed: EvalSeed ): Promise< () => Promise< void > > {
71-
const { localSite, connectedWpcomSites = [], snapshots = [] } = seed;
80+
const { localSite, connectedWpcomSites = [], snapshots = [], globalInstructions } = seed;
81+
82+
let restoreInstructions: ( () => Promise< void > ) | null = null;
83+
if ( typeof globalInstructions === 'string' ) {
84+
const prior = await readGlobalInstructionsFile();
85+
restoreInstructions =
86+
prior === null
87+
? () => rm( getGlobalInstructionsPath(), { force: true } )
88+
: () => writeGlobalInstructions( prior );
89+
await writeGlobalInstructions( globalInstructions );
90+
}
7291

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

102121
return async () => {
122+
await restoreInstructions?.().catch( () => undefined );
103123
for ( const site of seededConnections ) {
104124
await removeConnectedWpcomSite( site.localSiteId, site.id ).catch( () => undefined );
105125
}

‎apps/cli/ai/runtimes/pi/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type SessionManager,
2525
type ToolDefinition,
2626
} from '@earendil-works/pi-coding-agent';
27+
import { readGlobalInstructions } from '@studio/common/ai/global-instructions';
2728
import {
2829
DEFAULT_MODEL,
2930
getAiModelFamily,
@@ -293,6 +294,10 @@ async function createStudioAgentSession(
293294
const isRemoteSite = Boolean( config.activeSite?.remote && config.activeSite?.wpcomSiteId );
294295
const remoteSession = config.env.STUDIO_REMOTE_SESSION === '1';
295296
const chatArtifactsEnabled = typeof process.send === 'function';
297+
const [ userInstructions, runtime ] = await Promise.all( [
298+
readGlobalInstructions(),
299+
isRemoteSite ? undefined : resolveActiveSiteRuntime( config.activeSite ),
300+
] );
296301

297302
const systemPrompt = buildSystemPrompt(
298303
isRemoteSite
@@ -303,11 +308,13 @@ async function createStudioAgentSession(
303308
id: config.activeSite!.wpcomSiteId!,
304309
},
305310
remoteSession,
311+
userInstructions,
306312
}
307313
: {
308314
chatArtifactsEnabled,
309315
remoteSession,
310-
runtime: await resolveActiveSiteRuntime( config.activeSite ),
316+
runtime,
317+
userInstructions,
311318
}
312319
);
313320

‎apps/cli/ai/system-prompt.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,24 @@ export interface BuildSystemPromptOptions {
2323
// Runtime of the active local site. Playground (PHP WASM) needs extra WP-CLI
2424
// constraints that the native PHP runtime does not. Defaults to native-php.
2525
runtime?: SiteRuntime;
26+
// The user's global instructions (~/.studio/knowledge/instructions.md).
27+
userInstructions?: string;
2628
}
2729

30+
// Keep the always-injected instructions small enough that they can't crowd out
31+
// the rest of the system prompt.
32+
const USER_INSTRUCTIONS_MAX_LENGTH = 16_000;
33+
2834
export function buildSystemPrompt( options?: BuildSystemPromptOptions ): string {
2935
const remoteSessionAddendum = options?.remoteSession ? `\n\n${ REMOTE_SESSION_GUIDANCE }` : '';
36+
const userInstructionsSection = buildUserInstructionsSection( options?.userInstructions );
3037

3138
if ( options?.remoteSite ) {
3239
return `${ buildRemoteIntro( options.remoteSite ) }
3340
3441
${ REMOTE_CONTENT_GUIDELINES }
3542
36-
${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }
43+
${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }${ userInstructionsSection }
3744
`;
3845
}
3946

@@ -43,10 +50,30 @@ ${ REMOTE_DESIGN_GUIDELINES }${ remoteSessionAddendum }
4350
runtime: options?.runtime,
4451
} ) }
4552
46-
${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum }
53+
${ LOCAL_SKILL_ROUTING }${ remoteSessionAddendum }${ userInstructionsSection }
4754
`;
4855
}
4956

57+
function buildUserInstructionsSection( userInstructions?: string ): string {
58+
if ( ! userInstructions ) {
59+
return '';
60+
}
61+
const instructions =
62+
userInstructions.length > USER_INSTRUCTIONS_MAX_LENGTH
63+
? `${ userInstructions.slice(
64+
0,
65+
USER_INSTRUCTIONS_MAX_LENGTH
66+
) }\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.]`
67+
: userInstructions;
68+
return `
69+
70+
## User's global instructions
71+
72+
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.
73+
74+
${ instructions }`;
75+
}
76+
5077
function buildRemoteIntro( site: RemoteSiteContext ): string {
5178
return `${ AGENT_IDENTITY } You manage WordPress.com sites using the WordPress.com REST API.
5279

‎apps/cli/ai/tests/system-prompt.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,32 @@ describe( 'buildSystemPrompt', () => {
163163
expect( prompt ).not.toContain( 'Do not respond as though the user is looking at the capture' );
164164
} );
165165

166+
it( 'appends the user global instructions for local and remote sessions', () => {
167+
const variants = [ { chatArtifactsEnabled: true }, { remoteSite } ];
168+
for ( const variant of variants ) {
169+
const prompt = buildSystemPrompt( {
170+
...variant,
171+
userInstructions: 'Always answer in French.',
172+
} );
173+
expect( prompt ).toContain( "## User's global instructions" );
174+
expect( prompt ).toContain( 'Always answer in French.' );
175+
}
176+
} );
177+
178+
it( 'omits the global instructions section when none are set', () => {
179+
const prompts = [ buildSystemPrompt( {} ), buildSystemPrompt( { remoteSite } ) ];
180+
for ( const prompt of prompts ) {
181+
expect( prompt ).not.toContain( "## User's global instructions" );
182+
}
183+
} );
184+
185+
it( 'truncates oversized global instructions with a visible notice', () => {
186+
const prompt = buildSystemPrompt( { userInstructions: 'a'.repeat( 20_000 ) } );
187+
188+
expect( prompt ).toContain( 'was truncated here' );
189+
expect( prompt ).not.toContain( 'a'.repeat( 17_000 ) );
190+
} );
191+
166192
it( 'omits the terminal screenshot caveat for remote-bridge sessions', () => {
167193
// The Telegram user cannot open local file paths; delivery is covered
168194
// by the remote-session share_screenshot guidance instead.

‎apps/local/src/index.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { createWriteStream, existsSync, mkdtempSync, rm } from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
55
import { pipeline } from 'node:stream/promises';
6+
import {
7+
readGlobalInstructionsFile,
8+
writeGlobalInstructions,
9+
} from '@studio/common/ai/global-instructions';
610
import { isAiModelId } from '@studio/common/ai/models';
711
import { createAgentRunManager } from '@studio/common/ai/run-manager';
812
import {
@@ -423,6 +427,27 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
423427
} )
424428
);
425429

430+
// --- Agent instructions — the shared ~/.studio/knowledge/instructions.md --
431+
api.get(
432+
'/agent-instructions',
433+
asyncHandler( async ( _req: Request, res: Response ) => {
434+
res.json( { content: ( await readGlobalInstructionsFile() ) ?? '' } );
435+
} )
436+
);
437+
438+
api.post(
439+
'/agent-instructions',
440+
asyncHandler( async ( req: Request, res: Response ) => {
441+
const content = req.body?.content;
442+
if ( typeof content !== 'string' ) {
443+
res.status( 400 ).json( { error: 'content required' } );
444+
return;
445+
}
446+
await writeGlobalInstructions( content );
447+
res.status( 204 ).end();
448+
} )
449+
);
450+
426451
// --- Sites — the local machine's real Studio sites, via the CLI -----------
427452
api.get(
428453
'/sites',

‎apps/studio/src/ipc-handlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export {
229229

230230
export {
231231
getColorScheme,
232+
getGlobalAgentInstructions,
232233
getInstalledAppsAndTerminals,
233234
getQuitSitesBehavior,
234235
getUserEditor,
@@ -237,6 +238,7 @@ export {
237238
getWapuuScore,
238239
previewColorScheme,
239240
saveColorScheme,
241+
saveGlobalAgentInstructions,
240242
saveQuitSitesBehavior,
241243
saveUserEditor,
242244
saveUserLocale,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { getErrorMessage } from '@studio/common/lib/error-formatting';
2+
import { TextareaControl } from '@wordpress/components';
3+
import { useI18n } from '@wordpress/react-i18n';
4+
import { useEffect, useState } from 'react';
5+
import Button from 'src/components/button';
6+
import { getIpcApi } from 'src/lib/get-ipc-api';
7+
8+
export function StudioCodeTab() {
9+
const { __ } = useI18n();
10+
const [ content, setContent ] = useState< string | null >( null );
11+
const [ savedContent, setSavedContent ] = useState( '' );
12+
const [ isSaving, setIsSaving ] = useState( false );
13+
const [ error, setError ] = useState< string | null >( null );
14+
15+
useEffect( () => {
16+
let cancelled = false;
17+
getIpcApi()
18+
.getGlobalAgentInstructions()
19+
.then( ( instructions ) => {
20+
if ( ! cancelled ) {
21+
setContent( instructions );
22+
setSavedContent( instructions );
23+
}
24+
} )
25+
.catch( ( err ) => {
26+
if ( ! cancelled ) {
27+
setError( getErrorMessage( err ) ?? String( err ) );
28+
}
29+
} );
30+
return () => {
31+
cancelled = true;
32+
};
33+
}, [] );
34+
35+
const handleSave = async () => {
36+
if ( content === null ) {
37+
return;
38+
}
39+
setIsSaving( true );
40+
setError( null );
41+
try {
42+
await getIpcApi().saveGlobalAgentInstructions( content );
43+
setSavedContent( content );
44+
} catch ( err ) {
45+
setError( getErrorMessage( err ) ?? String( err ) );
46+
} finally {
47+
setIsSaving( false );
48+
}
49+
};
50+
51+
return (
52+
<div className="flex flex-col gap-4 pb-2">
53+
{ error && <div className="text-sm text-frame-error">{ error }</div> }
54+
55+
<TextareaControl
56+
__nextHasNoMarginBottom
57+
label={ __( 'Instructions' ) }
58+
help={ __(
59+
'Global instructions for the Studio Code agent. They are included in every new conversation, across all sites.'
60+
) }
61+
rows={ 12 }
62+
value={ content ?? '' }
63+
onChange={ setContent }
64+
disabled={ content === null }
65+
placeholder={ __( 'e.g. Always answer in French. My sites are for restaurants.' ) }
66+
/>
67+
68+
<div className="flex justify-end">
69+
<Button
70+
variant="primary"
71+
onClick={ handleSave }
72+
disabled={ content === null || isSaving || content === savedContent }
73+
>
74+
{ isSaving ? __( 'Saving…' ) : __( 'Save instructions' ) }
75+
</Button>
76+
</div>
77+
</div>
78+
);
79+
}

‎apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// To run tests, execute `npm run test -- src/modules/user-settings/components/tests/user-settings.test.tsx` from the root directory
2-
import { render, screen, waitFor } from '@testing-library/react';
2+
import { render, screen, waitFor, within } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import { Provider } from 'react-redux';
55
import { vi } from 'vitest';
@@ -48,6 +48,8 @@ const mockIpcApi = {
4848
saveColorScheme: vi.fn().mockResolvedValue( undefined ),
4949
getQuitSitesBehavior: vi.fn().mockResolvedValue( undefined ),
5050
saveQuitSitesBehavior: vi.fn().mockResolvedValue( undefined ),
51+
getGlobalAgentInstructions: vi.fn().mockResolvedValue( '' ),
52+
saveGlobalAgentInstructions: vi.fn().mockResolvedValue( undefined ),
5153
};
5254

5355
vi.mock( 'src/lib/get-ipc-api', () => ( {
@@ -161,12 +163,25 @@ describe( 'UserSettings', () => {
161163
expect( screen.getByText( 'Account' ) ).toHaveAttribute( 'aria-selected', 'true' );
162164
expect( screen.getByText( 'Log out' ) ).toBeInTheDocument();
163165
expect( screen.getByText( 'Preview sites' ) ).toBeInTheDocument();
164-
expect( screen.getByText( 'Studio Code' ) ).toBeInTheDocument();
166+
// Scoped to the panel: "Studio Code" also names a settings tab.
167+
expect(
168+
within( screen.getByRole( 'tabpanel' ) ).getByText( 'Studio Code' )
169+
).toBeInTheDocument();
165170
expect(
166171
screen.getByText( 'Studio Code limits are temporarily unavailable.' )
167172
).toBeInTheDocument();
168173
expect( screen.queryByText( /monthly prompts used/ ) ).not.toBeInTheDocument();
169174
} );
175+
176+
await user.click( screen.getByRole( 'tab', { name: 'Studio Code' } ) );
177+
178+
await waitFor( () => {
179+
expect( screen.getByRole( 'tab', { name: 'Studio Code' } ) ).toHaveAttribute(
180+
'aria-selected',
181+
'true'
182+
);
183+
expect( screen.getByLabelText( 'Instructions' ) ).toBeInTheDocument();
184+
} );
170185
} );
171186
} );
172187

‎apps/studio/src/modules/user-settings/components/user-settings.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { McpSettings } from 'src/modules/mcp/components/mcp-settings';
1111
import { AccountTab } from 'src/modules/user-settings/components/account-tab';
1212
import { PreferencesTab } from 'src/modules/user-settings/components/preferences-tab';
1313
import { SkillsTab } from 'src/modules/user-settings/components/skills-tab';
14+
import { StudioCodeTab } from 'src/modules/user-settings/components/studio-code-tab';
1415
import { UserSettingsTab } from 'src/modules/user-settings/user-settings-types';
1516
import { useRootSelector } from 'src/stores';
1617
import { snapshotSelectors } from 'src/stores/snapshot-slice';
@@ -84,6 +85,11 @@ export default function UserSettings() {
8485
title: __( 'Account' ),
8586
} );
8687

88+
result.push( {
89+
name: 'studio-code',
90+
title: __( 'Studio Code' ),
91+
} );
92+
8793
result.push( {
8894
name: 'skills',
8995
title: __( 'Skills' ),
@@ -119,6 +125,7 @@ export default function UserSettings() {
119125
{ ( { name } ) => (
120126
<div className="mt-6 px-8 pb-8 flex gap-4 flex-col">
121127
{ name === 'general' && <PreferencesTab onClose={ resetLocalState } /> }
128+
{ name === 'studio-code' && <StudioCodeTab /> }
122129
{ name === 'skills' && <SkillsTab /> }
123130
{ name === 'mcp' && <McpSettings /> }
124131
{ name === 'account' && (

0 commit comments

Comments
 (0)