Path: blob/main/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type * as vscode from 'vscode';6import { createServiceIdentifier } from '../../../util/common/services';7import { Emitter } from '../../../util/vs/base/common/event';8import { match } from '../../../util/vs/base/common/glob';9import { Disposable } from '../../../util/vs/base/common/lifecycle';10import { ResourceSet } from '../../../util/vs/base/common/map';11import { Schemas } from '../../../util/vs/base/common/network';12import { IObservable, observableFromEvent } from '../../../util/vs/base/common/observableInternal';13import { dirname, isAbsolute } from '../../../util/vs/base/common/path';14import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';15import { isObject } from '../../../util/vs/base/common/types';16import { URI } from '../../../util/vs/base/common/uri';17import { FileType, Uri } from '../../../vscodeTypes';18import { IRunCommandExecutionService } from '../../commands/common/runCommandExecutionService';19import { CodeGenerationImportInstruction, CodeGenerationTextInstruction, Config, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService';20import { INativeEnvService } from '../../env/common/envService';21import { IExtensionsService } from '../../extensions/common/extensionsService';22import { IFileSystemService } from '../../filesystem/common/fileSystemService';23import { ILogService } from '../../log/common/logService';24import { IPromptPathRepresentationService } from '../../prompts/common/promptPathRepresentationService';25import { IWorkspaceService } from '../../workspace/common/workspaceService';26import { COPILOT_INSTRUCTIONS_PATH, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_LOCATION_KEY, PERSONAL_SKILL_FOLDERS, PromptsType, SKILLS_LOCATION_KEY, USE_AGENT_SKILLS_SETTING, WORKSPACE_SKILL_FOLDERS } from './promptTypes';2728declare const TextDecoder: {29decode(input: Uint8Array): string;30new(): TextDecoder;31};3233export interface ICustomInstructions {34readonly kind: CustomInstructionsKind;35readonly content: IInstruction[];36readonly reference: vscode.Uri;37}3839export enum CustomInstructionsKind {40File,41Setting,42}4344export interface IInstruction {45readonly languageId?: string;46readonly instruction: string;47}4849export const ICustomInstructionsService = createServiceIdentifier<ICustomInstructionsService>('ICustomInstructionsService');5051export interface IExtensionPromptFile {52uri: URI;53type: PromptsType;54extensionId?: string;55}5657export const enum SkillStorage {58Extension = 'extension',59Internal = 'internal',60Personal = 'personal',61Workspace = 'workspace',62}6364export interface ISkillInfo {65readonly skillName: string;66readonly skillFolderUri: URI;67readonly storage: SkillStorage;68}6970export interface ICustomInstructionsService {71readonly _serviceBrand: undefined;72fetchInstructionsFromSetting(configKey: Config<CodeGenerationInstruction[]>): Promise<ICustomInstructions[]>;73fetchInstructionsFromFile(fileUri: Uri): Promise<ICustomInstructions | undefined>;7475getAgentInstructions(): Promise<URI[]>;7677parseInstructionIndexFile(promptFileIndexText: string): IInstructionIndexFile;7879isExternalInstructionsFile(uri: URI): Promise<boolean>;80isExternalInstructionsFolder(uri: URI): boolean;81isSkillFile(uri: URI): boolean;82isSkillMdFile(uri: URI): boolean;83getSkillInfo(uri: URI): ISkillInfo | undefined;8485/**86* Refreshes the cached extension prompt files by querying VS Code's extension prompt file provider.87* The cache is normally initialized lazily on first use in {@link isExternalInstructionsFile}, so88* callers only need to invoke this explicitly when they require the latest extension state before89* that first lookup or want to force a manual refresh of the cached prompt file list.90*/91refreshExtensionPromptFiles(): Promise<void>;92/** Gets skill info for extension-contributed skill files */93getExtensionSkillInfo(uri: URI): (ISkillInfo & { extensionId?: string }) | undefined;94}9596export interface IInstructionIndexFile {97readonly instructions: ResourceSet;98readonly skills: ResourceSet;99readonly skillFolders: ResourceSet;100readonly agents: Set<string>;101}102103export type CodeGenerationInstruction = { languagee?: string; text: string } | { languagee?: string; file: string };104105function isCodeGenerationImportInstruction(instruction: any): instruction is CodeGenerationImportInstruction {106if (typeof instruction === 'object' && instruction !== null) {107return typeof instruction.file === 'string' && (instruction.language === undefined || typeof instruction.language === 'string');108}109return false;110}111112function isCodeGenerationTextInstruction(instruction: any): instruction is CodeGenerationTextInstruction {113if (typeof instruction === 'object' && instruction !== null) {114return typeof instruction.text === 'string' && (instruction.language === undefined || typeof instruction.language === 'string');115}116return false;117}118119export class CustomInstructionsService extends Disposable implements ICustomInstructionsService {120121readonly _serviceBrand: undefined;122123readonly _matchInstructionLocationsFromConfig: IObservable<(uri: URI) => boolean>;124readonly _matchInstructionLocationsFromExtensions: IObservable<(uri: URI) => boolean>;125readonly _matchInstructionLocationsFromSkills: IObservable<(uri: URI) => ISkillInfo | undefined>;126127private _extensionPromptFilesCache: IExtensionPromptFile[] | undefined;128private readonly _onDidChangeExtensionPromptFilesCache = this._register(new Emitter<void>());129130constructor(131@IConfigurationService private readonly configurationService: IConfigurationService,132@INativeEnvService private readonly envService: INativeEnvService,133@IWorkspaceService private readonly workspaceService: IWorkspaceService,134@IFileSystemService private readonly fileSystemService: IFileSystemService,135@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,136@ILogService private readonly logService: ILogService,137@IExtensionsService private readonly extensionService: IExtensionsService,138@IRunCommandExecutionService private readonly runCommandExecutionService: IRunCommandExecutionService,139) {140super();141142this._matchInstructionLocationsFromConfig = observableFromEvent(143(handleChange) => this._register(configurationService.onDidChangeConfiguration(e => {144if (e.affectsConfiguration(INSTRUCTIONS_LOCATION_KEY)) {145handleChange(e);146}147})),148() => {149const sanitizedLocations: string[] = [];150const locations = this.configurationService.getNonExtensionConfig<Record<string, boolean>>(INSTRUCTIONS_LOCATION_KEY);151if (isObject(locations)) {152for (const key in locations) {153const location = key.trim();154const value = locations[key];155if (value === true) {156if (location.startsWith('~/')) {157sanitizedLocations.push(this.promptPathRepresentationService.getFilePath(extUriBiasedIgnorePathCase.joinPath(this.envService.userHome, location.substring(2))));158} else if (isAbsolute(location)) {159sanitizedLocations.push(location);160}161}162}163}164return ((uri: URI) => {165if (uri.scheme !== Schemas.file || !uri.path.endsWith(INSTRUCTION_FILE_EXTENSION) || sanitizedLocations.length === 0) {166return false;167}168const instructionFilePath = this.promptPathRepresentationService.getFilePath(uri);169const instructionFolderPath = dirname(instructionFilePath);170for (const location of sanitizedLocations) {171if (match(location, instructionFolderPath) || match(location, instructionFilePath)) {172return true;173}174}175return false;176});177}178);179180this._matchInstructionLocationsFromExtensions = observableFromEvent(181(handleChange) => this._register(this.extensionService.onDidChange(handleChange)),182() => {183const locations = new ResourceSet();184for (const extension of this.extensionService.all) {185186const chatInstructions = extension.packageJSON['contributes']?.['chatInstructions'];187if (Array.isArray(chatInstructions)) {188for (const contribution of chatInstructions) {189if (contribution.path) {190const folderUri = extUriBiasedIgnorePathCase.dirname(Uri.joinPath(extension.extensionUri, contribution.path));191locations.add(folderUri);192}193}194}195}196return ((uri: URI) => {197for (const location of locations) {198if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, location)) {199return true;200}201}202return false;203});204}205);206207this._matchInstructionLocationsFromSkills = observableFromEvent(208(handleChange) => {209const configurationDisposable = configurationService.onDidChangeConfiguration(e => {210if (e.affectsConfiguration(USE_AGENT_SKILLS_SETTING) || e.affectsConfiguration(SKILLS_LOCATION_KEY)) {211handleChange(e);212}213});214const workspaceDisposable = workspaceService.onDidChangeWorkspaceFolders(handleChange);215const cacheDisposable = this._onDidChangeExtensionPromptFilesCache.event(handleChange);216return {217dispose: () => {218configurationDisposable.dispose();219workspaceDisposable.dispose();220cacheDisposable.dispose();221}222};223},224() => {225if (this.configurationService.getNonExtensionConfig<boolean>(USE_AGENT_SKILLS_SETTING)) {226const personalSkillFolderUris = PERSONAL_SKILL_FOLDERS.map(folder => extUriBiasedIgnorePathCase.joinPath(this.envService.userHome, folder));227const workspaceSkillFolderUris = this.workspaceService.getWorkspaceFolders().flatMap(workspaceFolder =>228WORKSPACE_SKILL_FOLDERS.map(folder => extUriBiasedIgnorePathCase.joinPath(workspaceFolder, folder))229);230// Tagged list preserving the storage provenance for each folder231const taggedSkillFolderUris: { uri: URI; storage: SkillStorage }[] = [232...personalSkillFolderUris.map(uri => ({ uri, storage: SkillStorage.Personal as const })),233...workspaceSkillFolderUris.map(uri => ({ uri, storage: SkillStorage.Workspace as const })),234];235236// Get additional skill locations from config237const configSkillLocationUris: URI[] = [];238const locations = this.configurationService.getNonExtensionConfig<Record<string, boolean>>(SKILLS_LOCATION_KEY);239const userHome = this.envService.userHome;240const workspaceFolders = this.workspaceService.getWorkspaceFolders();241if (isObject(locations)) {242for (const key in locations) {243const location = key.trim();244const value = locations[key];245if (value !== true) {246continue;247}248// Expand ~/ to user home directory249if (location.startsWith('~/')) {250configSkillLocationUris.push(Uri.joinPath(userHome, location.substring(2)));251} else if (isAbsolute(location)) {252configSkillLocationUris.push(URI.file(location));253} else {254// Relative path - join to each workspace folder255for (const workspaceFolder of workspaceFolders) {256configSkillLocationUris.push(Uri.joinPath(workspaceFolder, location));257}258}259}260}261262return ((uri: URI) => {263// Check workspace and personal skill folders264for (const { uri: topLevelSkillFolderUri, storage } of taggedSkillFolderUris) {265if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, topLevelSkillFolderUri)) {266// Get the path segments relative to the skill folder267const relativePath = extUriBiasedIgnorePathCase.relativePath(topLevelSkillFolderUri, uri);268if (relativePath) {269// The skill directory is the first path segment under the skill folder270const skillName = relativePath.split('/')[0];271const skillFolderUri = extUriBiasedIgnorePathCase.joinPath(topLevelSkillFolderUri, skillName);272return { skillName, skillFolderUri, storage };273}274}275}276277// Check config-based skill locations278if (configSkillLocationUris.length > 0) {279for (const locationUri of configSkillLocationUris) {280if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, locationUri)) {281// Get the path segments relative to the skill folder282const relativePath = extUriBiasedIgnorePathCase.relativePath(locationUri, uri);283if (relativePath) {284// The skill directory is the first path segment under the skill folder285const skillName = relativePath.split('/')[0];286const skillFolderUri = extUriBiasedIgnorePathCase.joinPath(locationUri, skillName);287return { skillName, skillFolderUri, storage: SkillStorage.Workspace };288}289}290}291}292293// Check extension-contributed skills294return this.getExtensionSkillInfo(uri);295});296}297return (() => undefined);298}299);300}301302public async fetchInstructionsFromFile(fileUri: Uri): Promise<ICustomInstructions | undefined> {303return await this.readInstructionsFromFile(fileUri);304}305306public async getAgentInstructions(): Promise<URI[]> {307const result = [];308if (this.configurationService.getConfig(ConfigKey.UseInstructionFiles)) {309for (const folder of this.workspaceService.getWorkspaceFolders()) {310try {311const uri = extUriBiasedIgnorePathCase.joinPath(folder, COPILOT_INSTRUCTIONS_PATH);312if ((await this.fileSystemService.stat(uri)).type === FileType.File) {313result.push(uri);314}315} catch (e) {316// ignore non-existing instruction files317}318}319}320return result;321}322323public async fetchInstructionsFromSetting(configKey: Config<CodeGenerationInstruction[]>): Promise<ICustomInstructions[]> {324const result: ICustomInstructions[] = [];325326const instructions: IInstruction[] = [];327const seenFiles: Set<string> = new Set();328329const inspect = this.configurationService.inspectConfig(configKey);330if (inspect) {331await this.collectInstructionsFromSettings([inspect.workspaceFolderValue, inspect.workspaceValue, inspect.globalValue], seenFiles, instructions, result);332}333334const reference = Uri.from({ scheme: this.envService.uriScheme, authority: 'settings', path: `/${configKey.fullyQualifiedId}` });335if (instructions.length > 0) {336result.push({337kind: CustomInstructionsKind.Setting,338content: instructions,339reference,340});341}342return result;343}344345private async collectInstructionsFromSettings(instructionsArrays: (CodeGenerationInstruction[] | undefined)[], seenFiles: Set<string>, instructions: IInstruction[], result: ICustomInstructions[]): Promise<void> {346const seenInstructions: Set<string> = new Set();347for (const instructionsArray of instructionsArrays) {348if (Array.isArray(instructionsArray)) {349for (const entry of instructionsArray) {350if (isCodeGenerationImportInstruction(entry) && !seenFiles.has(entry.file)) {351seenFiles.add(entry.file);352await this._collectInstructionsFromFile(entry.file, entry.language, result);353}354if (isCodeGenerationTextInstruction(entry) && !seenInstructions.has(entry.text)) {355seenInstructions.add(entry.text);356instructions.push({ instruction: entry.text, languageId: entry.language });357}358}359}360}361}362363private async _collectInstructionsFromFile(customInstructionsFile: string, language: string | undefined, result: ICustomInstructions[]): Promise<void> {364this.logService.debug(`Collect instructions from file: ${customInstructionsFile}`);365const promises = this.workspaceService.getWorkspaceFolders().map(async folderUri => {366const fileUri = Uri.joinPath(folderUri, customInstructionsFile);367const instruction = await this.readInstructionsFromFile(fileUri, language);368if (instruction) {369result.push(instruction);370}371});372await Promise.all(promises);373}374375private async readInstructionsFromFile(fileUri: Uri, languageId?: string): Promise<ICustomInstructions | undefined> {376try {377const fileContents = await this.fileSystemService.readFile(fileUri);378const content = new TextDecoder().decode(fileContents);379const instruction = content.trim();380if (!instruction) {381this.logService.debug(`Instructions file is empty: ${fileUri.toString()}`);382return;383}384return {385kind: CustomInstructionsKind.File,386content: [{ instruction, languageId }],387reference: fileUri388};389} catch (e) {390this.logService.debug(`Instructions file not found: ${fileUri.toString()}`);391return undefined;392}393}394395public async refreshExtensionPromptFiles(): Promise<void> {396try {397const extensionPromptFiles = await this.runCommandExecutionService.executeCommand('vscode.extensionPromptFileProvider') as IExtensionPromptFile[] | undefined;398this._extensionPromptFilesCache = extensionPromptFiles ?? [];399} catch (e) {400this.logService.warn(`Error fetching extension prompt files: ${e}`);401this._extensionPromptFilesCache = [];402}403this._onDidChangeExtensionPromptFilesCache.fire();404}405406private isExtensionPromptFile(uri: URI): boolean {407if (!this._extensionPromptFilesCache) {408return false;409}410return this._extensionPromptFilesCache.some(file => {411if (file.type === 'skill') {412// For skills, the URI points to SKILL.md - allow everything under the parent folder413const skillFolderUri = extUriBiasedIgnorePathCase.dirname(file.uri);414return extUriBiasedIgnorePathCase.isEqualOrParent(uri, skillFolderUri);415}416return extUriBiasedIgnorePathCase.isEqual(file.uri, uri);417});418}419420public getExtensionSkillInfo(uri: URI): (ISkillInfo & { extensionId?: string }) | undefined {421if (!this._extensionPromptFilesCache) {422return undefined;423}424for (const file of this._extensionPromptFilesCache) {425if (file.type === 'skill') {426const skillFolderUri = extUriBiasedIgnorePathCase.dirname(file.uri);427if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, skillFolderUri)) {428const skillName = extUriBiasedIgnorePathCase.basename(skillFolderUri);429return { skillName, skillFolderUri, storage: SkillStorage.Extension, extensionId: file.extensionId };430}431}432}433return undefined;434}435436public parseInstructionIndexFile(content: string): InstructionIndexFile {437return new InstructionIndexFile(content, this.promptPathRepresentationService);438}439440public async isExternalInstructionsFile(uri: URI): Promise<boolean> {441if (uri.scheme === Schemas.vscodeUserData && uri.path.endsWith(INSTRUCTION_FILE_EXTENSION)) {442return true;443}444if (this._matchInstructionLocationsFromConfig.get()(uri)445|| this._matchInstructionLocationsFromExtensions.get()(uri)446|| this._matchInstructionLocationsFromSkills.get()(uri)) {447return true;448}449450// Check cached extension-contributed prompt files451if (this._extensionPromptFilesCache === undefined) {452// Cache not initialized yet, fetch it now453await this.refreshExtensionPromptFiles();454}455return this.isExtensionPromptFile(uri);456}457458public isExternalInstructionsFolder(uri: URI): boolean {459return this._matchInstructionLocationsFromExtensions.get()(uri)460|| this._matchInstructionLocationsFromSkills.get()(uri) !== undefined;461}462463public isSkillFile(uri: URI): boolean {464return this._matchInstructionLocationsFromSkills.get()(uri) !== undefined;465}466467public isSkillMdFile(uri: URI): boolean {468return this.isSkillFile(uri) && extUriBiasedIgnorePathCase.basename(uri).toLowerCase() === 'skill.md';469}470471public getSkillDirectory(uri: URI): URI | undefined {472const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri);473if (!skillInfo) {474return undefined;475}476return skillInfo.skillFolderUri;477}478479public getSkillName(uri: URI): string | undefined {480const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri);481if (!skillInfo) {482return undefined;483}484return skillInfo.skillName;485}486487public getSkillInfo(uri: URI): ISkillInfo | undefined {488return this._matchInstructionLocationsFromSkills.get()(uri);489}490}491492class InstructionIndexFile implements IInstructionIndexFile {493494private instructionUris: ResourceSet | undefined;495private skillUris: ResourceSet | undefined;496private skillFolderUris: ResourceSet | undefined;497private agentNames: Set<string> | undefined;498499constructor(500public readonly content: string,501@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService) {502}503504/**505* Finds file paths or names in the index file. The index file has XML format: <listElementName><elementName><propertyName>value</propertyName></elementName></listElementName>506*/507private getValuesInIndexFile(listElementName: string, elementName: string, propertyName: string): string[] {508const result: string[] = [];509const lists = xmlContents(this.content, listElementName);510for (const list of lists) {511const instructions = xmlContents(list, elementName);512for (const instruction of instructions) {513const filePath = xmlContents(instruction, propertyName);514if (filePath.length > 0) {515result.push(filePath[0]);516}517}518}519return result;520}521522private getURIsFromFilePaths(filePaths: string[]): ResourceSet {523const result = new ResourceSet();524for (const filePath of filePaths) {525const uri = this.promptPathRepresentationService.resolveFilePath(filePath);526if (uri) {527result.add(uri);528if (uri.scheme === Schemas.vscodeUserData) {529result.add(URI.from({ scheme: Schemas.file, path: uri.path }));530}531}532}533return result;534}535536get instructions(): ResourceSet {537if (this.instructionUris === undefined) {538this.instructionUris = this.getURIsFromFilePaths(this.getValuesInIndexFile('instructions', 'instruction', 'file'));539}540return this.instructionUris;541}542543get skills(): ResourceSet {544if (this.skillUris === undefined) {545this.skillUris = this.getURIsFromFilePaths(this.getValuesInIndexFile('skills', 'skill', 'file'));546}547return this.skillUris;548}549550get skillFolders(): ResourceSet {551if (this.skillFolderUris === undefined) {552this.skillFolderUris = new ResourceSet();553for (const skillUri of this.skills) {554const skillFolderUri = extUriBiasedIgnorePathCase.dirname(skillUri);555this.skillFolderUris.add(skillFolderUri);556}557}558return this.skillFolderUris;559}560561get agents(): Set<string> {562if (this.agentNames === undefined) {563this.agentNames = new Set(this.getValuesInIndexFile('agents', 'agent', 'file'));564}565return this.agentNames;566}567}568569function xmlContents(text: string, tag: string): string[] {570const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');571const matches = [];572let match;573while ((match = regex.exec(text)) !== null) {574matches.push(match[1].trim());575}576return matches;577}578579580