Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type * as vscode from 'vscode';
7
import { createServiceIdentifier } from '../../../util/common/services';
8
import { Emitter } from '../../../util/vs/base/common/event';
9
import { match } from '../../../util/vs/base/common/glob';
10
import { Disposable } from '../../../util/vs/base/common/lifecycle';
11
import { ResourceSet } from '../../../util/vs/base/common/map';
12
import { Schemas } from '../../../util/vs/base/common/network';
13
import { IObservable, observableFromEvent } from '../../../util/vs/base/common/observableInternal';
14
import { dirname, isAbsolute } from '../../../util/vs/base/common/path';
15
import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
16
import { isObject } from '../../../util/vs/base/common/types';
17
import { URI } from '../../../util/vs/base/common/uri';
18
import { FileType, Uri } from '../../../vscodeTypes';
19
import { IRunCommandExecutionService } from '../../commands/common/runCommandExecutionService';
20
import { CodeGenerationImportInstruction, CodeGenerationTextInstruction, Config, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService';
21
import { INativeEnvService } from '../../env/common/envService';
22
import { IExtensionsService } from '../../extensions/common/extensionsService';
23
import { IFileSystemService } from '../../filesystem/common/fileSystemService';
24
import { ILogService } from '../../log/common/logService';
25
import { IPromptPathRepresentationService } from '../../prompts/common/promptPathRepresentationService';
26
import { IWorkspaceService } from '../../workspace/common/workspaceService';
27
import { 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';
28
29
declare const TextDecoder: {
30
decode(input: Uint8Array): string;
31
new(): TextDecoder;
32
};
33
34
export interface ICustomInstructions {
35
readonly kind: CustomInstructionsKind;
36
readonly content: IInstruction[];
37
readonly reference: vscode.Uri;
38
}
39
40
export enum CustomInstructionsKind {
41
File,
42
Setting,
43
}
44
45
export interface IInstruction {
46
readonly languageId?: string;
47
readonly instruction: string;
48
}
49
50
export const ICustomInstructionsService = createServiceIdentifier<ICustomInstructionsService>('ICustomInstructionsService');
51
52
export interface IExtensionPromptFile {
53
uri: URI;
54
type: PromptsType;
55
extensionId?: string;
56
}
57
58
export const enum SkillStorage {
59
Extension = 'extension',
60
Internal = 'internal',
61
Personal = 'personal',
62
Workspace = 'workspace',
63
}
64
65
export interface ISkillInfo {
66
readonly skillName: string;
67
readonly skillFolderUri: URI;
68
readonly storage: SkillStorage;
69
}
70
71
export interface ICustomInstructionsService {
72
readonly _serviceBrand: undefined;
73
fetchInstructionsFromSetting(configKey: Config<CodeGenerationInstruction[]>): Promise<ICustomInstructions[]>;
74
fetchInstructionsFromFile(fileUri: Uri): Promise<ICustomInstructions | undefined>;
75
76
getAgentInstructions(): Promise<URI[]>;
77
78
parseInstructionIndexFile(promptFileIndexText: string): IInstructionIndexFile;
79
80
isExternalInstructionsFile(uri: URI): Promise<boolean>;
81
isExternalInstructionsFolder(uri: URI): boolean;
82
isSkillFile(uri: URI): boolean;
83
isSkillMdFile(uri: URI): boolean;
84
getSkillInfo(uri: URI): ISkillInfo | undefined;
85
86
/**
87
* Refreshes the cached extension prompt files by querying VS Code's extension prompt file provider.
88
* The cache is normally initialized lazily on first use in {@link isExternalInstructionsFile}, so
89
* callers only need to invoke this explicitly when they require the latest extension state before
90
* that first lookup or want to force a manual refresh of the cached prompt file list.
91
*/
92
refreshExtensionPromptFiles(): Promise<void>;
93
/** Gets skill info for extension-contributed skill files */
94
getExtensionSkillInfo(uri: URI): (ISkillInfo & { extensionId?: string }) | undefined;
95
}
96
97
export interface IInstructionIndexFile {
98
readonly instructions: ResourceSet;
99
readonly skills: ResourceSet;
100
readonly skillFolders: ResourceSet;
101
readonly agents: Set<string>;
102
}
103
104
export type CodeGenerationInstruction = { languagee?: string; text: string } | { languagee?: string; file: string };
105
106
function isCodeGenerationImportInstruction(instruction: any): instruction is CodeGenerationImportInstruction {
107
if (typeof instruction === 'object' && instruction !== null) {
108
return typeof instruction.file === 'string' && (instruction.language === undefined || typeof instruction.language === 'string');
109
}
110
return false;
111
}
112
113
function isCodeGenerationTextInstruction(instruction: any): instruction is CodeGenerationTextInstruction {
114
if (typeof instruction === 'object' && instruction !== null) {
115
return typeof instruction.text === 'string' && (instruction.language === undefined || typeof instruction.language === 'string');
116
}
117
return false;
118
}
119
120
export class CustomInstructionsService extends Disposable implements ICustomInstructionsService {
121
122
readonly _serviceBrand: undefined;
123
124
readonly _matchInstructionLocationsFromConfig: IObservable<(uri: URI) => boolean>;
125
readonly _matchInstructionLocationsFromExtensions: IObservable<(uri: URI) => boolean>;
126
readonly _matchInstructionLocationsFromSkills: IObservable<(uri: URI) => ISkillInfo | undefined>;
127
128
private _extensionPromptFilesCache: IExtensionPromptFile[] | undefined;
129
private readonly _onDidChangeExtensionPromptFilesCache = this._register(new Emitter<void>());
130
131
constructor(
132
@IConfigurationService private readonly configurationService: IConfigurationService,
133
@INativeEnvService private readonly envService: INativeEnvService,
134
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
135
@IFileSystemService private readonly fileSystemService: IFileSystemService,
136
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService,
137
@ILogService private readonly logService: ILogService,
138
@IExtensionsService private readonly extensionService: IExtensionsService,
139
@IRunCommandExecutionService private readonly runCommandExecutionService: IRunCommandExecutionService,
140
) {
141
super();
142
143
this._matchInstructionLocationsFromConfig = observableFromEvent(
144
(handleChange) => this._register(configurationService.onDidChangeConfiguration(e => {
145
if (e.affectsConfiguration(INSTRUCTIONS_LOCATION_KEY)) {
146
handleChange(e);
147
}
148
})),
149
() => {
150
const sanitizedLocations: string[] = [];
151
const locations = this.configurationService.getNonExtensionConfig<Record<string, boolean>>(INSTRUCTIONS_LOCATION_KEY);
152
if (isObject(locations)) {
153
for (const key in locations) {
154
const location = key.trim();
155
const value = locations[key];
156
if (value === true) {
157
if (location.startsWith('~/')) {
158
sanitizedLocations.push(this.promptPathRepresentationService.getFilePath(extUriBiasedIgnorePathCase.joinPath(this.envService.userHome, location.substring(2))));
159
} else if (isAbsolute(location)) {
160
sanitizedLocations.push(location);
161
}
162
}
163
}
164
}
165
return ((uri: URI) => {
166
if (uri.scheme !== Schemas.file || !uri.path.endsWith(INSTRUCTION_FILE_EXTENSION) || sanitizedLocations.length === 0) {
167
return false;
168
}
169
const instructionFilePath = this.promptPathRepresentationService.getFilePath(uri);
170
const instructionFolderPath = dirname(instructionFilePath);
171
for (const location of sanitizedLocations) {
172
if (match(location, instructionFolderPath) || match(location, instructionFilePath)) {
173
return true;
174
}
175
}
176
return false;
177
});
178
}
179
);
180
181
this._matchInstructionLocationsFromExtensions = observableFromEvent(
182
(handleChange) => this._register(this.extensionService.onDidChange(handleChange)),
183
() => {
184
const locations = new ResourceSet();
185
for (const extension of this.extensionService.all) {
186
187
const chatInstructions = extension.packageJSON['contributes']?.['chatInstructions'];
188
if (Array.isArray(chatInstructions)) {
189
for (const contribution of chatInstructions) {
190
if (contribution.path) {
191
const folderUri = extUriBiasedIgnorePathCase.dirname(Uri.joinPath(extension.extensionUri, contribution.path));
192
locations.add(folderUri);
193
}
194
}
195
}
196
}
197
return ((uri: URI) => {
198
for (const location of locations) {
199
if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, location)) {
200
return true;
201
}
202
}
203
return false;
204
});
205
}
206
);
207
208
this._matchInstructionLocationsFromSkills = observableFromEvent(
209
(handleChange) => {
210
const configurationDisposable = configurationService.onDidChangeConfiguration(e => {
211
if (e.affectsConfiguration(USE_AGENT_SKILLS_SETTING) || e.affectsConfiguration(SKILLS_LOCATION_KEY)) {
212
handleChange(e);
213
}
214
});
215
const workspaceDisposable = workspaceService.onDidChangeWorkspaceFolders(handleChange);
216
const cacheDisposable = this._onDidChangeExtensionPromptFilesCache.event(handleChange);
217
return {
218
dispose: () => {
219
configurationDisposable.dispose();
220
workspaceDisposable.dispose();
221
cacheDisposable.dispose();
222
}
223
};
224
},
225
() => {
226
if (this.configurationService.getNonExtensionConfig<boolean>(USE_AGENT_SKILLS_SETTING)) {
227
const personalSkillFolderUris = PERSONAL_SKILL_FOLDERS.map(folder => extUriBiasedIgnorePathCase.joinPath(this.envService.userHome, folder));
228
const workspaceSkillFolderUris = this.workspaceService.getWorkspaceFolders().flatMap(workspaceFolder =>
229
WORKSPACE_SKILL_FOLDERS.map(folder => extUriBiasedIgnorePathCase.joinPath(workspaceFolder, folder))
230
);
231
// Tagged list preserving the storage provenance for each folder
232
const taggedSkillFolderUris: { uri: URI; storage: SkillStorage }[] = [
233
...personalSkillFolderUris.map(uri => ({ uri, storage: SkillStorage.Personal as const })),
234
...workspaceSkillFolderUris.map(uri => ({ uri, storage: SkillStorage.Workspace as const })),
235
];
236
237
// Get additional skill locations from config
238
const configSkillLocationUris: URI[] = [];
239
const locations = this.configurationService.getNonExtensionConfig<Record<string, boolean>>(SKILLS_LOCATION_KEY);
240
const userHome = this.envService.userHome;
241
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
242
if (isObject(locations)) {
243
for (const key in locations) {
244
const location = key.trim();
245
const value = locations[key];
246
if (value !== true) {
247
continue;
248
}
249
// Expand ~/ to user home directory
250
if (location.startsWith('~/')) {
251
configSkillLocationUris.push(Uri.joinPath(userHome, location.substring(2)));
252
} else if (isAbsolute(location)) {
253
configSkillLocationUris.push(URI.file(location));
254
} else {
255
// Relative path - join to each workspace folder
256
for (const workspaceFolder of workspaceFolders) {
257
configSkillLocationUris.push(Uri.joinPath(workspaceFolder, location));
258
}
259
}
260
}
261
}
262
263
return ((uri: URI) => {
264
// Check workspace and personal skill folders
265
for (const { uri: topLevelSkillFolderUri, storage } of taggedSkillFolderUris) {
266
if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, topLevelSkillFolderUri)) {
267
// Get the path segments relative to the skill folder
268
const relativePath = extUriBiasedIgnorePathCase.relativePath(topLevelSkillFolderUri, uri);
269
if (relativePath) {
270
// The skill directory is the first path segment under the skill folder
271
const skillName = relativePath.split('/')[0];
272
const skillFolderUri = extUriBiasedIgnorePathCase.joinPath(topLevelSkillFolderUri, skillName);
273
return { skillName, skillFolderUri, storage };
274
}
275
}
276
}
277
278
// Check config-based skill locations
279
if (configSkillLocationUris.length > 0) {
280
for (const locationUri of configSkillLocationUris) {
281
if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, locationUri)) {
282
// Get the path segments relative to the skill folder
283
const relativePath = extUriBiasedIgnorePathCase.relativePath(locationUri, uri);
284
if (relativePath) {
285
// The skill directory is the first path segment under the skill folder
286
const skillName = relativePath.split('/')[0];
287
const skillFolderUri = extUriBiasedIgnorePathCase.joinPath(locationUri, skillName);
288
return { skillName, skillFolderUri, storage: SkillStorage.Workspace };
289
}
290
}
291
}
292
}
293
294
// Check extension-contributed skills
295
return this.getExtensionSkillInfo(uri);
296
});
297
}
298
return (() => undefined);
299
}
300
);
301
}
302
303
public async fetchInstructionsFromFile(fileUri: Uri): Promise<ICustomInstructions | undefined> {
304
return await this.readInstructionsFromFile(fileUri);
305
}
306
307
public async getAgentInstructions(): Promise<URI[]> {
308
const result = [];
309
if (this.configurationService.getConfig(ConfigKey.UseInstructionFiles)) {
310
for (const folder of this.workspaceService.getWorkspaceFolders()) {
311
try {
312
const uri = extUriBiasedIgnorePathCase.joinPath(folder, COPILOT_INSTRUCTIONS_PATH);
313
if ((await this.fileSystemService.stat(uri)).type === FileType.File) {
314
result.push(uri);
315
}
316
} catch (e) {
317
// ignore non-existing instruction files
318
}
319
}
320
}
321
return result;
322
}
323
324
public async fetchInstructionsFromSetting(configKey: Config<CodeGenerationInstruction[]>): Promise<ICustomInstructions[]> {
325
const result: ICustomInstructions[] = [];
326
327
const instructions: IInstruction[] = [];
328
const seenFiles: Set<string> = new Set();
329
330
const inspect = this.configurationService.inspectConfig(configKey);
331
if (inspect) {
332
await this.collectInstructionsFromSettings([inspect.workspaceFolderValue, inspect.workspaceValue, inspect.globalValue], seenFiles, instructions, result);
333
}
334
335
const reference = Uri.from({ scheme: this.envService.uriScheme, authority: 'settings', path: `/${configKey.fullyQualifiedId}` });
336
if (instructions.length > 0) {
337
result.push({
338
kind: CustomInstructionsKind.Setting,
339
content: instructions,
340
reference,
341
});
342
}
343
return result;
344
}
345
346
private async collectInstructionsFromSettings(instructionsArrays: (CodeGenerationInstruction[] | undefined)[], seenFiles: Set<string>, instructions: IInstruction[], result: ICustomInstructions[]): Promise<void> {
347
const seenInstructions: Set<string> = new Set();
348
for (const instructionsArray of instructionsArrays) {
349
if (Array.isArray(instructionsArray)) {
350
for (const entry of instructionsArray) {
351
if (isCodeGenerationImportInstruction(entry) && !seenFiles.has(entry.file)) {
352
seenFiles.add(entry.file);
353
await this._collectInstructionsFromFile(entry.file, entry.language, result);
354
}
355
if (isCodeGenerationTextInstruction(entry) && !seenInstructions.has(entry.text)) {
356
seenInstructions.add(entry.text);
357
instructions.push({ instruction: entry.text, languageId: entry.language });
358
}
359
}
360
}
361
}
362
}
363
364
private async _collectInstructionsFromFile(customInstructionsFile: string, language: string | undefined, result: ICustomInstructions[]): Promise<void> {
365
this.logService.debug(`Collect instructions from file: ${customInstructionsFile}`);
366
const promises = this.workspaceService.getWorkspaceFolders().map(async folderUri => {
367
const fileUri = Uri.joinPath(folderUri, customInstructionsFile);
368
const instruction = await this.readInstructionsFromFile(fileUri, language);
369
if (instruction) {
370
result.push(instruction);
371
}
372
});
373
await Promise.all(promises);
374
}
375
376
private async readInstructionsFromFile(fileUri: Uri, languageId?: string): Promise<ICustomInstructions | undefined> {
377
try {
378
const fileContents = await this.fileSystemService.readFile(fileUri);
379
const content = new TextDecoder().decode(fileContents);
380
const instruction = content.trim();
381
if (!instruction) {
382
this.logService.debug(`Instructions file is empty: ${fileUri.toString()}`);
383
return;
384
}
385
return {
386
kind: CustomInstructionsKind.File,
387
content: [{ instruction, languageId }],
388
reference: fileUri
389
};
390
} catch (e) {
391
this.logService.debug(`Instructions file not found: ${fileUri.toString()}`);
392
return undefined;
393
}
394
}
395
396
public async refreshExtensionPromptFiles(): Promise<void> {
397
try {
398
const extensionPromptFiles = await this.runCommandExecutionService.executeCommand('vscode.extensionPromptFileProvider') as IExtensionPromptFile[] | undefined;
399
this._extensionPromptFilesCache = extensionPromptFiles ?? [];
400
} catch (e) {
401
this.logService.warn(`Error fetching extension prompt files: ${e}`);
402
this._extensionPromptFilesCache = [];
403
}
404
this._onDidChangeExtensionPromptFilesCache.fire();
405
}
406
407
private isExtensionPromptFile(uri: URI): boolean {
408
if (!this._extensionPromptFilesCache) {
409
return false;
410
}
411
return this._extensionPromptFilesCache.some(file => {
412
if (file.type === 'skill') {
413
// For skills, the URI points to SKILL.md - allow everything under the parent folder
414
const skillFolderUri = extUriBiasedIgnorePathCase.dirname(file.uri);
415
return extUriBiasedIgnorePathCase.isEqualOrParent(uri, skillFolderUri);
416
}
417
return extUriBiasedIgnorePathCase.isEqual(file.uri, uri);
418
});
419
}
420
421
public getExtensionSkillInfo(uri: URI): (ISkillInfo & { extensionId?: string }) | undefined {
422
if (!this._extensionPromptFilesCache) {
423
return undefined;
424
}
425
for (const file of this._extensionPromptFilesCache) {
426
if (file.type === 'skill') {
427
const skillFolderUri = extUriBiasedIgnorePathCase.dirname(file.uri);
428
if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, skillFolderUri)) {
429
const skillName = extUriBiasedIgnorePathCase.basename(skillFolderUri);
430
return { skillName, skillFolderUri, storage: SkillStorage.Extension, extensionId: file.extensionId };
431
}
432
}
433
}
434
return undefined;
435
}
436
437
public parseInstructionIndexFile(content: string): InstructionIndexFile {
438
return new InstructionIndexFile(content, this.promptPathRepresentationService);
439
}
440
441
public async isExternalInstructionsFile(uri: URI): Promise<boolean> {
442
if (uri.scheme === Schemas.vscodeUserData && uri.path.endsWith(INSTRUCTION_FILE_EXTENSION)) {
443
return true;
444
}
445
if (this._matchInstructionLocationsFromConfig.get()(uri)
446
|| this._matchInstructionLocationsFromExtensions.get()(uri)
447
|| this._matchInstructionLocationsFromSkills.get()(uri)) {
448
return true;
449
}
450
451
// Check cached extension-contributed prompt files
452
if (this._extensionPromptFilesCache === undefined) {
453
// Cache not initialized yet, fetch it now
454
await this.refreshExtensionPromptFiles();
455
}
456
return this.isExtensionPromptFile(uri);
457
}
458
459
public isExternalInstructionsFolder(uri: URI): boolean {
460
return this._matchInstructionLocationsFromExtensions.get()(uri)
461
|| this._matchInstructionLocationsFromSkills.get()(uri) !== undefined;
462
}
463
464
public isSkillFile(uri: URI): boolean {
465
return this._matchInstructionLocationsFromSkills.get()(uri) !== undefined;
466
}
467
468
public isSkillMdFile(uri: URI): boolean {
469
return this.isSkillFile(uri) && extUriBiasedIgnorePathCase.basename(uri).toLowerCase() === 'skill.md';
470
}
471
472
public getSkillDirectory(uri: URI): URI | undefined {
473
const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri);
474
if (!skillInfo) {
475
return undefined;
476
}
477
return skillInfo.skillFolderUri;
478
}
479
480
public getSkillName(uri: URI): string | undefined {
481
const skillInfo = this._matchInstructionLocationsFromSkills.get()(uri);
482
if (!skillInfo) {
483
return undefined;
484
}
485
return skillInfo.skillName;
486
}
487
488
public getSkillInfo(uri: URI): ISkillInfo | undefined {
489
return this._matchInstructionLocationsFromSkills.get()(uri);
490
}
491
}
492
493
class InstructionIndexFile implements IInstructionIndexFile {
494
495
private instructionUris: ResourceSet | undefined;
496
private skillUris: ResourceSet | undefined;
497
private skillFolderUris: ResourceSet | undefined;
498
private agentNames: Set<string> | undefined;
499
500
constructor(
501
public readonly content: string,
502
@IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService) {
503
}
504
505
/**
506
* Finds file paths or names in the index file. The index file has XML format: <listElementName><elementName><propertyName>value</propertyName></elementName></listElementName>
507
*/
508
private getValuesInIndexFile(listElementName: string, elementName: string, propertyName: string): string[] {
509
const result: string[] = [];
510
const lists = xmlContents(this.content, listElementName);
511
for (const list of lists) {
512
const instructions = xmlContents(list, elementName);
513
for (const instruction of instructions) {
514
const filePath = xmlContents(instruction, propertyName);
515
if (filePath.length > 0) {
516
result.push(filePath[0]);
517
}
518
}
519
}
520
return result;
521
}
522
523
private getURIsFromFilePaths(filePaths: string[]): ResourceSet {
524
const result = new ResourceSet();
525
for (const filePath of filePaths) {
526
const uri = this.promptPathRepresentationService.resolveFilePath(filePath);
527
if (uri) {
528
result.add(uri);
529
if (uri.scheme === Schemas.vscodeUserData) {
530
result.add(URI.from({ scheme: Schemas.file, path: uri.path }));
531
}
532
}
533
}
534
return result;
535
}
536
537
get instructions(): ResourceSet {
538
if (this.instructionUris === undefined) {
539
this.instructionUris = this.getURIsFromFilePaths(this.getValuesInIndexFile('instructions', 'instruction', 'file'));
540
}
541
return this.instructionUris;
542
}
543
544
get skills(): ResourceSet {
545
if (this.skillUris === undefined) {
546
this.skillUris = this.getURIsFromFilePaths(this.getValuesInIndexFile('skills', 'skill', 'file'));
547
}
548
return this.skillUris;
549
}
550
551
get skillFolders(): ResourceSet {
552
if (this.skillFolderUris === undefined) {
553
this.skillFolderUris = new ResourceSet();
554
for (const skillUri of this.skills) {
555
const skillFolderUri = extUriBiasedIgnorePathCase.dirname(skillUri);
556
this.skillFolderUris.add(skillFolderUri);
557
}
558
}
559
return this.skillFolderUris;
560
}
561
562
get agents(): Set<string> {
563
if (this.agentNames === undefined) {
564
this.agentNames = new Set(this.getValuesInIndexFile('agents', 'agent', 'file'));
565
}
566
return this.agentNames;
567
}
568
}
569
570
function xmlContents(text: string, tag: string): string[] {
571
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
572
const matches = [];
573
let match;
574
while ((match = regex.exec(text)) !== null) {
575
matches.push(match[1].trim());
576
}
577
return matches;
578
}
579
580