@theia/ai-core 1.75.0-next.8 → 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/lib/browser/agent-plugin-ui-bridge.d.ts +26 -0
  2. package/lib/browser/agent-plugin-ui-bridge.d.ts.map +1 -0
  3. package/lib/browser/agent-plugin-ui-bridge.js +20 -0
  4. package/lib/browser/agent-plugin-ui-bridge.js.map +1 -0
  5. package/lib/browser/ai-core-command-contribution.d.ts.map +1 -1
  6. package/lib/browser/ai-core-command-contribution.js +4 -1
  7. package/lib/browser/ai-core-command-contribution.js.map +1 -1
  8. package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
  9. package/lib/browser/ai-core-frontend-module.js +6 -0
  10. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  11. package/lib/browser/ai-settings-service.js +1 -0
  12. package/lib/browser/ai-settings-service.js.map +1 -1
  13. package/lib/browser/frontend-language-model-alias-registry.js +1 -1
  14. package/lib/browser/frontend-language-model-registry.d.ts +2 -0
  15. package/lib/browser/frontend-language-model-registry.d.ts.map +1 -1
  16. package/lib/browser/frontend-language-model-registry.js +22 -1
  17. package/lib/browser/frontend-language-model-registry.js.map +1 -1
  18. package/lib/browser/generic-capabilities-variable-contribution.d.ts.map +1 -1
  19. package/lib/browser/generic-capabilities-variable-contribution.js +4 -1
  20. package/lib/browser/generic-capabilities-variable-contribution.js.map +1 -1
  21. package/lib/browser/hide-ai-preferences-contribution.d.ts +30 -0
  22. package/lib/browser/hide-ai-preferences-contribution.d.ts.map +1 -0
  23. package/lib/browser/hide-ai-preferences-contribution.js +90 -0
  24. package/lib/browser/hide-ai-preferences-contribution.js.map +1 -0
  25. package/lib/browser/hide-ai-preferences-contribution.spec.d.ts +2 -0
  26. package/lib/browser/hide-ai-preferences-contribution.spec.d.ts.map +1 -0
  27. package/lib/browser/hide-ai-preferences-contribution.spec.js +95 -0
  28. package/lib/browser/hide-ai-preferences-contribution.spec.js.map +1 -0
  29. package/lib/browser/index.d.ts +3 -0
  30. package/lib/browser/index.d.ts.map +1 -1
  31. package/lib/browser/index.js +3 -0
  32. package/lib/browser/index.js.map +1 -1
  33. package/lib/browser/prompt-variable-contribution.d.ts +1 -1
  34. package/lib/browser/prompt-variable-contribution.d.ts.map +1 -1
  35. package/lib/browser/prompt-variable-contribution.js +1 -0
  36. package/lib/browser/prompt-variable-contribution.js.map +1 -1
  37. package/lib/browser/skill-prompt-coordinator.d.ts +9 -1
  38. package/lib/browser/skill-prompt-coordinator.d.ts.map +1 -1
  39. package/lib/browser/skill-prompt-coordinator.js +29 -14
  40. package/lib/browser/skill-prompt-coordinator.js.map +1 -1
  41. package/lib/browser/skill-prompt-coordinator.spec.d.ts +2 -0
  42. package/lib/browser/skill-prompt-coordinator.spec.d.ts.map +1 -0
  43. package/lib/browser/skill-prompt-coordinator.spec.js +98 -0
  44. package/lib/browser/skill-prompt-coordinator.spec.js.map +1 -0
  45. package/lib/browser/skill-registry-ui-bridge.d.ts +25 -0
  46. package/lib/browser/skill-registry-ui-bridge.d.ts.map +1 -0
  47. package/lib/browser/skill-registry-ui-bridge.js +20 -0
  48. package/lib/browser/skill-registry-ui-bridge.js.map +1 -0
  49. package/lib/browser/skill-service.d.ts +33 -8
  50. package/lib/browser/skill-service.d.ts.map +1 -1
  51. package/lib/browser/skill-service.js +95 -39
  52. package/lib/browser/skill-service.js.map +1 -1
  53. package/lib/browser/skill-service.spec.js +181 -2
  54. package/lib/browser/skill-service.spec.js.map +1 -1
  55. package/lib/browser/skills-variable-contribution.d.ts +4 -0
  56. package/lib/browser/skills-variable-contribution.d.ts.map +1 -1
  57. package/lib/browser/skills-variable-contribution.js +10 -1
  58. package/lib/browser/skills-variable-contribution.js.map +1 -1
  59. package/lib/browser/skills-variable-contribution.spec.js +6 -0
  60. package/lib/browser/skills-variable-contribution.spec.js.map +1 -1
  61. package/lib/browser/theia-variable-contribution.js +6 -12
  62. package/lib/browser/theia-variable-contribution.js.map +1 -1
  63. package/lib/common/ai-core-preferences.d.ts +17 -0
  64. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  65. package/lib/common/ai-core-preferences.js +9 -1
  66. package/lib/common/ai-core-preferences.js.map +1 -1
  67. package/lib/common/ai-variable-resource.d.ts +2 -1
  68. package/lib/common/ai-variable-resource.d.ts.map +1 -1
  69. package/lib/common/ai-variable-resource.js +6 -1
  70. package/lib/common/ai-variable-resource.js.map +1 -1
  71. package/lib/common/capability-variable-contribution.d.ts +1 -1
  72. package/lib/common/capability-variable-contribution.d.ts.map +1 -1
  73. package/lib/common/capability-variable-contribution.js +1 -0
  74. package/lib/common/capability-variable-contribution.js.map +1 -1
  75. package/lib/common/language-model.d.ts +16 -2
  76. package/lib/common/language-model.d.ts.map +1 -1
  77. package/lib/common/language-model.js +39 -3
  78. package/lib/common/language-model.js.map +1 -1
  79. package/lib/common/language-model.spec.js +75 -0
  80. package/lib/common/language-model.spec.js.map +1 -1
  81. package/lib/common/prompt-service.d.ts.map +1 -1
  82. package/lib/common/prompt-service.js +7 -4
  83. package/lib/common/prompt-service.js.map +1 -1
  84. package/lib/common/prompt-service.spec.js +36 -0
  85. package/lib/common/prompt-service.spec.js.map +1 -1
  86. package/lib/common/skill.d.ts +31 -7
  87. package/lib/common/skill.d.ts.map +1 -1
  88. package/lib/common/skill.js +37 -14
  89. package/lib/common/skill.js.map +1 -1
  90. package/lib/common/skill.spec.js +70 -0
  91. package/lib/common/skill.spec.js.map +1 -1
  92. package/lib/common/today-variable-contribution.d.ts.map +1 -1
  93. package/lib/common/today-variable-contribution.js +1 -1
  94. package/lib/common/today-variable-contribution.js.map +1 -1
  95. package/lib/common/tool-invocation-registry.d.ts +2 -1
  96. package/lib/common/tool-invocation-registry.d.ts.map +1 -1
  97. package/lib/common/tool-invocation-registry.js +6 -1
  98. package/lib/common/tool-invocation-registry.js.map +1 -1
  99. package/lib/node/language-model-frontend-delegate.d.ts +2 -2
  100. package/lib/node/language-model-frontend-delegate.d.ts.map +1 -1
  101. package/lib/node/language-model-frontend-delegate.js +1 -0
  102. package/lib/node/language-model-frontend-delegate.js.map +1 -1
  103. package/package.json +12 -12
  104. package/src/browser/agent-plugin-ui-bridge.ts +43 -0
  105. package/src/browser/ai-core-command-contribution.ts +7 -1
  106. package/src/browser/ai-core-frontend-module.ts +11 -2
  107. package/src/browser/ai-settings-service.ts +2 -2
  108. package/src/browser/frontend-language-model-alias-registry.ts +1 -1
  109. package/src/browser/frontend-language-model-registry.ts +6 -2
  110. package/src/browser/generic-capabilities-variable-contribution.ts +4 -1
  111. package/src/browser/hide-ai-preferences-contribution.spec.ts +117 -0
  112. package/src/browser/hide-ai-preferences-contribution.ts +88 -0
  113. package/src/browser/index.ts +3 -0
  114. package/src/browser/prompt-variable-contribution.ts +3 -3
  115. package/src/browser/skill-prompt-coordinator.spec.ts +114 -0
  116. package/src/browser/skill-prompt-coordinator.ts +29 -17
  117. package/src/browser/skill-registry-ui-bridge.ts +41 -0
  118. package/src/browser/skill-service.spec.ts +229 -4
  119. package/src/browser/skill-service.ts +120 -43
  120. package/src/browser/skills-variable-contribution.spec.ts +6 -0
  121. package/src/browser/skills-variable-contribution.ts +14 -4
  122. package/src/browser/theia-variable-contribution.ts +12 -12
  123. package/src/common/ai-core-preferences.ts +19 -0
  124. package/src/common/ai-variable-resource.ts +6 -3
  125. package/src/common/capability-variable-contribution.ts +3 -3
  126. package/src/common/language-model.spec.ts +83 -0
  127. package/src/common/language-model.ts +35 -5
  128. package/src/common/prompt-service.spec.ts +43 -0
  129. package/src/common/prompt-service.ts +8 -6
  130. package/src/common/skill.spec.ts +99 -0
  131. package/src/common/skill.ts +52 -17
  132. package/src/common/today-variable-contribution.ts +2 -1
  133. package/src/common/tool-invocation-registry.ts +5 -2
  134. package/src/node/language-model-frontend-delegate.ts +3 -3
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
18
18
  import { Deferred } from '@theia/core/lib/common/promise-util';
19
- import { DisposableCollection, Emitter, Event, ILogger, URI } from '@theia/core';
19
+ import { ContributionProvider, Disposable, DisposableCollection, Emitter, Event, ILogger, MaybePromise, URI } from '@theia/core';
20
20
  import { Path } from '@theia/core/lib/common/path';
21
21
  import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
22
22
  import { FileService } from '@theia/filesystem/lib/browser/file-service';
@@ -26,6 +26,7 @@ import { AICorePreferences, PREFERENCE_NAME_SKILL_DIRECTORIES } from '../common/
26
26
  import {
27
27
  Skill,
28
28
  SkillDescription,
29
+ SkillDirectoryEntry,
29
30
  SKILL_FILE_NAME,
30
31
  validateSkillDescription,
31
32
  parseSkillFile,
@@ -35,12 +36,28 @@ import {
35
36
  /** Debounce delay for coalescing rapid file system events */
36
37
  const UPDATE_DEBOUNCE_MS = 50;
37
38
 
39
+ export const SkillDirectoryContribution = Symbol('SkillDirectoryContribution');
40
+ /**
41
+ * Contributes further roots to scan for skills. Point this at the *parent* of the skill folders,
42
+ * never at a skill folder itself. Contributed roots form the lowest-precedence `plugin` tier, so a
43
+ * directory the user controls always wins a path collision.
44
+ */
45
+ export interface SkillDirectoryContribution {
46
+ /** Entries carrying a {@link SkillDirectoryEntry.qualifier} have their skills prefixed with it. */
47
+ getSkillDirectories(): MaybePromise<SkillDirectoryEntry[]>;
48
+ readonly onDidChange?: Event<void>;
49
+ }
50
+
38
51
  export const SkillService = Symbol('SkillService');
39
52
  export interface SkillService {
40
53
  /** Get all discovered skills */
41
54
  getSkills(): Skill[];
42
55
 
43
- /** Get a skill by name */
56
+ /**
57
+ * Matched against {@link Skill.qualifiedName} first, then the plain
58
+ * {@link SkillDescription.name} so unqualified references keep resolving. A plain name shared by
59
+ * several skills is ambiguous and resolves to `undefined`.
60
+ */
44
61
  getSkill(name: string): Skill | undefined;
45
62
 
46
63
  /** Event fired when skills change */
@@ -51,15 +68,14 @@ export interface SkillService {
51
68
  }
52
69
 
53
70
  @injectable()
54
- export class DefaultSkillService implements SkillService {
71
+ export class DefaultSkillService implements SkillService, Disposable {
55
72
  @inject(AICorePreferences)
56
73
  protected readonly preferences: AICorePreferences;
57
74
 
58
75
  @inject(FileService)
59
76
  protected readonly fileService: FileService;
60
77
 
61
- @inject(ILogger)
62
- @named('SkillService')
78
+ @inject(ILogger) @named('ai-core:DefaultSkillService')
63
79
  protected readonly logger: ILogger;
64
80
 
65
81
  @inject(EnvVariablesServer)
@@ -68,8 +84,16 @@ export class DefaultSkillService implements SkillService {
68
84
  @inject(WorkspaceService)
69
85
  protected readonly workspaceService: WorkspaceService;
70
86
 
87
+ @inject(ContributionProvider)
88
+ @named(SkillDirectoryContribution)
89
+ protected readonly skillDirectoryContributions: ContributionProvider<SkillDirectoryContribution>;
90
+
91
+ /** Keyed by {@link Skill.qualifiedName}. */
71
92
  protected skills = new Map<string, Skill>();
93
+ /** Watchers of the current scan; disposed and rebuilt on every rescan. */
72
94
  protected toDispose = new DisposableCollection();
95
+ /** Kept apart from {@link toDispose}, which does not survive a rescan. */
96
+ protected readonly toDisposeOnServiceDispose = new DisposableCollection();
73
97
  protected watchedDirectories = new Set<string>();
74
98
  protected parentWatchers = new Map<string, string>();
75
99
 
@@ -91,6 +115,12 @@ export class DefaultSkillService implements SkillService {
91
115
 
92
116
  @postConstruct()
93
117
  protected init(): void {
118
+ for (const contribution of this.skillDirectoryContributions.getContributions()) {
119
+ if (contribution.onDidChange) {
120
+ this.toDisposeOnServiceDispose.push(contribution.onDidChange(() => this.scheduleUpdate()));
121
+ }
122
+ }
123
+
94
124
  this.fileService.onDidFilesChange(async (event: FileChangesEvent) => {
95
125
  for (const change of event.changes) {
96
126
  if (change.type === FileChangeType.ADDED) {
@@ -131,29 +161,43 @@ export class DefaultSkillService implements SkillService {
131
161
  }
132
162
  });
133
163
 
134
- // Wait for workspace to be ready before initial update
135
- this.workspaceService.ready.then(() => {
136
- this.update().then(() => {
137
- this._ready.resolve();
138
- // Only after initial update, start listening for changes
139
- this.lastSkillDirectoriesValue = JSON.stringify(this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES]);
140
-
141
- this.preferences.onPreferenceChanged(event => {
142
- if (event.preferenceName === PREFERENCE_NAME_SKILL_DIRECTORIES) {
143
- const currentValue = JSON.stringify(this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES]);
144
- if (currentValue === this.lastSkillDirectoriesValue) {
145
- return;
146
- }
147
- this.lastSkillDirectoriesValue = currentValue;
148
- this.scheduleUpdate();
149
- }
150
- });
164
+ this.initializeSkills();
165
+ }
151
166
 
152
- this.workspaceService.onWorkspaceChanged(() => {
153
- this.scheduleUpdate();
154
- });
155
- });
167
+ /** Runs the initial scan once the skill directory sources are known. {@link ready} always resolves, even if the scan failed. */
168
+ protected async initializeSkills(): Promise<void> {
169
+ try {
170
+ // The workspace contributes the workspace skill directories, the preferences the configured ones.
171
+ await Promise.all([this.workspaceService.ready, this.preferences.ready]);
172
+ } catch (error) {
173
+ this.logger.error('Failed to resolve skill directory sources, scanning the already known directories', error);
174
+ }
175
+
176
+ // Listen for changes before the initial scan, otherwise a change landing while it runs is recorded as
177
+ // already applied by the snapshot below and dropped. update()'s in-progress guard coalesces the overlap.
178
+ this.lastSkillDirectoriesValue = JSON.stringify(this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES]);
179
+
180
+ this.preferences.onPreferenceChanged(event => {
181
+ if (event.preferenceName === PREFERENCE_NAME_SKILL_DIRECTORIES) {
182
+ const currentValue = JSON.stringify(this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES]);
183
+ if (currentValue === this.lastSkillDirectoriesValue) {
184
+ return;
185
+ }
186
+ this.lastSkillDirectoriesValue = currentValue;
187
+ this.scheduleUpdate();
188
+ }
156
189
  });
190
+
191
+ this.workspaceService.onWorkspaceChanged(() => {
192
+ this.scheduleUpdate();
193
+ });
194
+
195
+ try {
196
+ await this.update();
197
+ } catch (error) {
198
+ this.logger.error('Initial skill scan failed', error);
199
+ }
200
+ this._ready.resolve();
157
201
  }
158
202
 
159
203
  getSkills(): Skill[] {
@@ -161,7 +205,19 @@ export class DefaultSkillService implements SkillService {
161
205
  }
162
206
 
163
207
  getSkill(name: string): Skill | undefined {
164
- return this.skills.get(name);
208
+ const qualifiedMatch = this.skills.get(name);
209
+ if (qualifiedMatch) {
210
+ return qualifiedMatch;
211
+ }
212
+ // Ambiguous plain names resolve to nothing: guessing would silently load a skill the caller
213
+ // did not ask for.
214
+ const plainMatches = this.getSkills().filter(skill => skill.name === name);
215
+ return plainMatches.length === 1 ? plainMatches[0] : undefined;
216
+ }
217
+
218
+ dispose(): void {
219
+ this.toDisposeOnServiceDispose.dispose();
220
+ this.toDispose.dispose();
165
221
  }
166
222
 
167
223
  protected scheduleUpdate(): void {
@@ -213,21 +269,25 @@ export class DefaultSkillService implements SkillService {
213
269
  const configuredDirectories = (this.preferences[PREFERENCE_NAME_SKILL_DIRECTORIES] ?? [])
214
270
  .map(dir => Path.untildify(dir, homePath));
215
271
  const defaultSkillsDirs = await this.getDefaultSkillsDirectoryPaths();
272
+ const pluginSkillsDirs = await this.getContributedSkillDirectories();
216
273
 
217
274
  const newWatchedDirectories = new Set<string>();
218
275
  const newParentWatchers = new Map<string, string>();
219
276
 
220
- const allDirectories = combineSkillDirectories(workspaceSkillsDirs, configuredDirectories, defaultSkillsDirs);
221
- for (const { path: directoryPath, tier } of allDirectories) {
277
+ const allDirectories = combineSkillDirectories(workspaceSkillsDirs, configuredDirectories, defaultSkillsDirs, pluginSkillsDirs);
278
+ for (const { path: directoryPath, tier, qualifier } of allDirectories) {
222
279
  if (tier === 'configured') {
223
- await this.processConfiguredSkillDirectory(directoryPath, newSkills, newDisposables, newWatchedDirectories);
280
+ await this.processConfiguredSkillDirectory(directoryPath, newSkills, newDisposables, newWatchedDirectories, qualifier);
224
281
  } else {
282
+ // `plugin` roots take this path too: a plugin with no `skills` directory is a
283
+ // plugin without skills, worth watching for rather than warning about.
225
284
  await this.processSkillDirectoryWithParentWatching(
226
285
  directoryPath,
227
286
  newSkills,
228
287
  newDisposables,
229
288
  newWatchedDirectories,
230
- newParentWatchers
289
+ newParentWatchers,
290
+ qualifier
231
291
  );
232
292
  }
233
293
  }
@@ -244,6 +304,19 @@ export class DefaultSkillService implements SkillService {
244
304
  this.onSkillsChangedEmitter.fire();
245
305
  }
246
306
 
307
+ /** A failing contribution is skipped: one broken root must not cost every other skill. */
308
+ protected async getContributedSkillDirectories(): Promise<SkillDirectoryEntry[]> {
309
+ const entries: SkillDirectoryEntry[] = [];
310
+ for (const contribution of this.skillDirectoryContributions.getContributions()) {
311
+ try {
312
+ entries.push(...await contribution.getSkillDirectories());
313
+ } catch (error) {
314
+ this.logger.error(`Failed to collect contributed skill directories: ${error}`);
315
+ }
316
+ }
317
+ return entries;
318
+ }
319
+
247
320
  protected getWorkspaceSkillsDirectoryPaths(): string[] {
248
321
  return this.workspaceService.tryGetRoots().flatMap(root => [
249
322
  root.resource.resolve('.prompts/skills').path.fsPath(),
@@ -267,7 +340,8 @@ export class DefaultSkillService implements SkillService {
267
340
  skills: Map<string, Skill>,
268
341
  disposables: DisposableCollection,
269
342
  watchedDirectories: Set<string>,
270
- parentWatchers: Map<string, string>
343
+ parentWatchers: Map<string, string>,
344
+ qualifier?: string
271
345
  ): Promise<void> {
272
346
  const dirURI = URI.fromFilePath(directoryPath);
273
347
 
@@ -275,7 +349,7 @@ export class DefaultSkillService implements SkillService {
275
349
  const dirExists = await this.fileService.exists(dirURI);
276
350
 
277
351
  if (dirExists) {
278
- await this.processExistingSkillDirectory(dirURI, skills, disposables, watchedDirectories);
352
+ await this.processExistingSkillDirectory(dirURI, skills, disposables, watchedDirectories, qualifier);
279
353
  } else {
280
354
  const parentPath = dirURI.parent.path.fsPath();
281
355
  const parentURI = URI.fromFilePath(parentPath);
@@ -299,7 +373,8 @@ export class DefaultSkillService implements SkillService {
299
373
  directoryPath: string,
300
374
  skills: Map<string, Skill>,
301
375
  disposables: DisposableCollection,
302
- watchedDirectories: Set<string>
376
+ watchedDirectories: Set<string>,
377
+ qualifier?: string
303
378
  ): Promise<void> {
304
379
  const dirURI = URI.fromFilePath(directoryPath);
305
380
 
@@ -311,7 +386,7 @@ export class DefaultSkillService implements SkillService {
311
386
  return;
312
387
  }
313
388
 
314
- await this.processExistingSkillDirectory(dirURI, skills, disposables, watchedDirectories);
389
+ await this.processExistingSkillDirectory(dirURI, skills, disposables, watchedDirectories, qualifier);
315
390
  } catch (error) {
316
391
  this.logger.error(`Error processing configured directory '${directoryPath}': ${error}`);
317
392
  }
@@ -321,7 +396,8 @@ export class DefaultSkillService implements SkillService {
321
396
  dirURI: URI,
322
397
  skills: Map<string, Skill>,
323
398
  disposables: DisposableCollection,
324
- watchedDirectories: Set<string>
399
+ watchedDirectories: Set<string>,
400
+ qualifier?: string
325
401
  ): Promise<void> {
326
402
  const stat = await this.fileService.resolve(dirURI);
327
403
  if (!stat.children) {
@@ -331,14 +407,14 @@ export class DefaultSkillService implements SkillService {
331
407
  for (const child of stat.children) {
332
408
  if (child.isDirectory) {
333
409
  const directoryName = child.name;
334
- await this.loadSkillFromDirectory(child.resource, directoryName, skills);
410
+ await this.loadSkillFromDirectory(child.resource, directoryName, skills, qualifier);
335
411
  }
336
412
  }
337
413
 
338
414
  this.setupDirectoryWatcher(dirURI, disposables, watchedDirectories);
339
415
  }
340
416
 
341
- protected async loadSkillFromDirectory(directoryUri: URI, directoryName: string, skills: Map<string, Skill>): Promise<void> {
417
+ protected async loadSkillFromDirectory(directoryUri: URI, directoryName: string, skills: Map<string, Skill>, qualifier?: string): Promise<void> {
342
418
  const skillFileUri = directoryUri.resolve(SKILL_FILE_NAME);
343
419
 
344
420
  const fileExists = await this.fileService.exists(skillFileUri);
@@ -366,19 +442,20 @@ export class DefaultSkillService implements SkillService {
366
442
  return;
367
443
  }
368
444
 
369
- const skillName = parsed.metadata.name;
445
+ const qualifiedName = Skill.qualifyName(parsed.metadata.name, qualifier);
370
446
 
371
- if (skills.has(skillName)) {
372
- this.logger.warn(`Skill '${skillName}': Duplicate skill found in '${directoryName}', using first discovered instance`);
447
+ if (skills.has(qualifiedName)) {
448
+ this.logger.warn(`Skill '${qualifiedName}': Duplicate skill found in '${directoryName}', using first discovered instance`);
373
449
  return;
374
450
  }
375
451
 
376
452
  const skill: Skill = {
377
453
  ...parsed.metadata,
378
- location: skillFileUri.path.fsPath()
454
+ location: skillFileUri.path.fsPath(),
455
+ qualifiedName
379
456
  };
380
457
 
381
- skills.set(skillName, skill);
458
+ skills.set(qualifiedName, skill);
382
459
  } catch (error) {
383
460
  this.logger.error(`Failed to load skill from '${directoryName}': ${error}`);
384
461
  }
@@ -163,11 +163,13 @@ describe('SkillsVariableContribution', () => {
163
163
  const skills: Skill[] = [
164
164
  {
165
165
  name: 'pdf-processing',
166
+ qualifiedName: 'pdf-processing',
166
167
  description: 'Processes PDF documents and extracts text content',
167
168
  location: '/path/to/skills/pdf-processing/SKILL.md'
168
169
  },
169
170
  {
170
171
  name: 'data-analysis',
172
+ qualifiedName: 'data-analysis',
171
173
  description: 'Analyzes data sets and generates reports',
172
174
  location: '/path/to/skills/data-analysis/SKILL.md'
173
175
  }
@@ -207,6 +209,7 @@ describe('SkillsVariableContribution', () => {
207
209
  const skills: Skill[] = [
208
210
  {
209
211
  name: 'test-skill',
212
+ qualifiedName: 'test-skill',
210
213
  description: 'Handles <tags> & "quotes" with \'apostrophes\'',
211
214
  location: '/path/to/skill/SKILL.md'
212
215
  }
@@ -228,6 +231,7 @@ describe('SkillsVariableContribution', () => {
228
231
  const skills: Skill[] = [
229
232
  {
230
233
  name: 'skill<test>',
234
+ qualifiedName: 'skill<test>',
231
235
  description: 'Test skill',
232
236
  location: '/path/with/&special/chars'
233
237
  }
@@ -266,6 +270,7 @@ describe('SkillsVariableContribution', () => {
266
270
  it('should return skill content when skill found', async () => {
267
271
  const skill: Skill = {
268
272
  name: 'my-skill',
273
+ qualifiedName: 'my-skill',
269
274
  description: 'A test skill',
270
275
  location: '/path/to/skills/my-skill/SKILL.md'
271
276
  };
@@ -293,6 +298,7 @@ This is the skill content.`
293
298
  it('should return undefined when file read fails', async () => {
294
299
  const skill: Skill = {
295
300
  name: 'my-skill',
301
+ qualifiedName: 'my-skill',
296
302
  description: 'A test skill',
297
303
  location: '/path/to/skills/my-skill/SKILL.md'
298
304
  };
@@ -14,7 +14,7 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
 
17
- import { inject, injectable } from '@theia/core/shared/inversify';
17
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
18
18
  import { ILogger, MaybePromise, nls, URI } from '@theia/core';
19
19
  import {
20
20
  AIVariable, AIVariableContext, AIVariableContribution, AIVariableResolutionRequest,
@@ -22,7 +22,7 @@ import {
22
22
  } from '../common/variable-service';
23
23
  import { FileService } from '@theia/filesystem/lib/browser/file-service';
24
24
  import { SkillService } from './skill-service';
25
- import { parseSkillFile } from '../common/skill';
25
+ import { parseSkillFile, Skill } from '../common/skill';
26
26
 
27
27
  export const SKILLS_VARIABLE: AIVariable = {
28
28
  id: 'skills',
@@ -39,6 +39,7 @@ export const SKILL_VARIABLE: AIVariable = {
39
39
  };
40
40
 
41
41
  export interface SkillSummary {
42
+ /** The name the model has to write to address the skill, i.e. {@link Skill.qualifiedName}. */
42
43
  name: string;
43
44
  description: string;
44
45
  location: string;
@@ -54,7 +55,7 @@ export class SkillsVariableContribution implements AIVariableContribution, AIVar
54
55
  @inject(SkillService)
55
56
  protected readonly skillService: SkillService;
56
57
 
57
- @inject(ILogger)
58
+ @inject(ILogger) @named('ai-core:SkillsVariableContribution')
58
59
  protected readonly logger: ILogger;
59
60
 
60
61
  @inject(FileService)
@@ -80,11 +81,20 @@ export class SkillsVariableContribution implements AIVariableContribution, AIVar
80
81
 
81
82
  // Handle plural skills variable
82
83
  if (request.variable.name === SKILLS_VARIABLE.name) {
83
- return this.resolveSkillsVariable(this.skillService.getSkills(), SKILLS_VARIABLE);
84
+ return this.resolveSkillsVariable(this.skillService.getSkills().map(skill => this.toSkillSummary(skill)), SKILLS_VARIABLE);
84
85
  }
85
86
  return undefined;
86
87
  }
87
88
 
89
+ /** Named by {@link Skill.qualifiedName}: that is what the model has to write to load it again. */
90
+ toSkillSummary(skill: Skill): SkillSummary {
91
+ return {
92
+ name: skill.qualifiedName,
93
+ description: skill.description,
94
+ location: skill.location
95
+ };
96
+ }
97
+
88
98
  /**
89
99
  * Resolves skills into a ResolvedSkillsVariable with XML format.
90
100
  */
@@ -51,40 +51,40 @@ export class TheiaVariableContribution implements AIVariableContribution, AIVari
51
51
  ['file', [
52
52
  {
53
53
  name: 'currentAbsoluteFilePath',
54
- description: nls.localize('theia/ai/core/variable-contribution/currentAbsoluteFilePath', 'The absolute path of the \
55
- currently opened file.')
54
+ description: nls.localize('theia/ai/core/variable-contribution/currentAbsoluteFilePath',
55
+ 'The absolute path of the currently opened file.')
56
56
  }
57
57
  ]],
58
58
  ['selectedText', [
59
59
  {
60
- description: nls.localize('theia/ai/core/variable-contribution/currentSelectedText', 'The plain text that is currently selected in the \
61
- opened file. This excludes the information where the content is coming from.')
60
+ description: nls.localize('theia/ai/core/variable-contribution/currentSelectedText',
61
+ 'The plain text that is currently selected in the opened file. This excludes the information where the content is coming from.')
62
62
  }
63
63
  ]],
64
64
  ['currentText', [
65
65
  {
66
66
  name: 'currentFileContent',
67
- description: nls.localize('theia/ai/core/variable-contribution/currentFileContent', 'The plain content of the \
68
- currently opened file. This excludes the information where the content is coming from.')
67
+ description: nls.localize('theia/ai/core/variable-contribution/currentFileContent',
68
+ 'The plain content of the currently opened file. This excludes the information where the content is coming from.')
69
69
  }
70
70
  ]],
71
71
  ['relativeFile', [
72
72
  {
73
73
  name: 'currentRelativeFilePath',
74
- description: nls.localize('theia/ai/core/variable-contribution/currentRelativeFilePath', 'The workspace-relative path of the \
75
- currently opened file (e.g., my-project/src/index.ts).')
74
+ description: nls.localize('theia/ai/core/variable-contribution/currentRelativeFilePath',
75
+ 'The workspace-relative path of the currently opened file (e.g., my-project/src/index.ts).')
76
76
  },
77
77
  {
78
78
  name: '_f',
79
- description: nls.localize('theia/ai/core/variable-contribution/dotRelativePath', 'Short reference to the workspace-relative path of the \
80
- currently opened file (\'currentRelativeFilePath\').')
79
+ description: nls.localize('theia/ai/core/variable-contribution/dotRelativePath',
80
+ 'Short reference to the workspace-relative path of the currently opened file (\'currentRelativeFilePath\').')
81
81
  }
82
82
  ]],
83
83
  ['relativeFileDirname', [
84
84
  {
85
85
  name: 'currentRelativeDirPath',
86
- description: nls.localize('theia/ai/core/variable-contribution/currentRelativeDirPath', 'The workspace-relative path of the directory \
87
- containing the currently opened file (e.g., my-project/src).')
86
+ description: nls.localize('theia/ai/core/variable-contribution/currentRelativeDirPath',
87
+ 'The workspace-relative path of the directory containing the currently opened file (e.g., my-project/src).')
88
88
  }
89
89
  ]],
90
90
  ['lineNumber', [{}]],
@@ -28,6 +28,25 @@ import { ReasoningSettings, SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM } fro
28
28
  import { PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
29
29
 
30
30
  export const AI_CORE_PREFERENCES_TITLE = nls.localize('theia/ai-core/preferences/title', 'AI Features');
31
+
32
+ /**
33
+ * Key under a preference's schema `typeDetails` where a language-model provider declares the
34
+ * human-readable name of its provider block. The AI Configuration view's Models page reads this to
35
+ * label the provider node (discovered from the `ai-features.<provider>.*` preferences) without
36
+ * having to hard-code provider names. Declare it on any one preference of the provider's block, e.g.
37
+ * `typeDetails: { [MODEL_PROVIDER_TYPE_DETAIL]: 'Anthropic' }`.
38
+ */
39
+ export const MODEL_PROVIDER_TYPE_DETAIL = 'aiModelProvider';
40
+
41
+ /**
42
+ * Shape a language-model provider stores under a preference's `typeDetails[MODEL_PROVIDER_TYPE_DETAIL]`
43
+ * to describe its `ai-features.<provider>.*` block to the AI Configuration view's Models page. Declared
44
+ * on any one preference of the block, e.g. `typeDetails: { [MODEL_PROVIDER_TYPE_DETAIL]: { label: 'Anthropic' } }`.
45
+ */
46
+ export interface ModelProviderTypeDetail {
47
+ /** Human-readable provider name shown as the provider node/card label. */
48
+ label: string;
49
+ }
31
50
  export const PREFERENCE_NAME_PROMPT_TEMPLATES = 'ai-features.promptTemplates.promptTemplatesFolder';
32
51
  export const PREFERENCE_NAME_REQUEST_SETTINGS = 'ai-features.modelSettings.requestSettings';
33
52
  export const PREFERENCE_NAME_REASONING = 'ai-features.reasoning.defaults';
@@ -15,8 +15,8 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import * as deepEqual from 'fast-deep-equal';
18
- import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
19
- import { Resource, URI, generateUuid } from '@theia/core';
18
+ import { inject, injectable, postConstruct, named } from '@theia/core/shared/inversify';
19
+ import { Resource, URI, generateUuid, ILogger } from '@theia/core';
20
20
  import { AIVariableContext, AIVariableResolutionRequest } from './variable-service';
21
21
  import stableJsonStringify = require('fast-json-stable-stringify');
22
22
  import { ConfigurableInMemoryResources, ConfigurableMutableReferenceResource } from './configurable-in-memory-resources';
@@ -28,6 +28,9 @@ export const NO_CONTEXT_AUTHORITY = 'context-free';
28
28
  export class AIVariableResourceResolver {
29
29
  @inject(ConfigurableInMemoryResources) protected readonly inMemoryResources: ConfigurableInMemoryResources;
30
30
 
31
+ @inject(ILogger) @named('ai-core:AIVariableResourceResolver')
32
+ protected readonly logger: ILogger;
33
+
31
34
  @postConstruct()
32
35
  protected init(): void {
33
36
  this.inMemoryResources.onWillDispose(resource => this.cache.delete(resource.uri.toString()));
@@ -68,7 +71,7 @@ export class AIVariableResourceResolver {
68
71
  }
69
72
  } catch (err) {
70
73
  // Mostly that deep equal could overflow the stack, but it should run into === or inequality before that.
71
- console.warn('Problem evaluating context in AIVariableResourceResolver', err);
74
+ this.logger.warn('Problem evaluating context in AIVariableResourceResolver', err);
72
75
  }
73
76
  return generateUuid();
74
77
  }
@@ -14,7 +14,7 @@
14
14
  // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  // *****************************************************************************
16
16
  import { ILogger, nls } from '@theia/core';
17
- import { injectable, inject } from '@theia/core/shared/inversify';
17
+ import { injectable, inject, named } from '@theia/core/shared/inversify';
18
18
  import * as monaco from '@theia/monaco-editor-core';
19
19
  import {
20
20
  AIVariable,
@@ -49,8 +49,8 @@ export class CapabilityVariableContribution implements AIVariableContribution, A
49
49
  @inject(PromptService)
50
50
  protected readonly promptService: PromptService;
51
51
 
52
- @inject(ILogger)
53
- protected logger: ILogger;
52
+ @inject(ILogger) @named('ai-core:CapabilityVariableContribution')
53
+ protected readonly logger: ILogger;
54
54
 
55
55
  registerVariables(service: AIVariableService): void {
56
56
  service.registerResolver(CAPABILITY_VARIABLE, this);
@@ -21,6 +21,7 @@ import {
21
21
  isLanguageModelStreamResponsePart,
22
22
  isModelMatching,
23
23
  isToolCallContent,
24
+ isToolCallHtmlAppResult,
24
25
  LanguageModel,
25
26
  LanguageModelSelector,
26
27
  resolveCompactionDefault,
@@ -260,3 +261,85 @@ describe('compaction contract', () => {
260
261
  expect(resolveServerSideCompaction(true, true, { enabled: false })).to.equal(false);
261
262
  });
262
263
  });
264
+
265
+ describe('isToolCallHtmlAppResult', () => {
266
+ it('returns true for valid html app result', () => {
267
+ expect(isToolCallHtmlAppResult({ type: 'html', html: '<div>Hello</div>' })).to.be.true;
268
+ });
269
+
270
+ it('returns true with optional title', () => {
271
+ expect(isToolCallHtmlAppResult({ type: 'html', html: '<p>App</p>', title: 'My App' })).to.be.true;
272
+ });
273
+
274
+ it('returns false for text result', () => {
275
+ expect(isToolCallHtmlAppResult({ type: 'text', text: 'hello' })).to.be.false;
276
+ });
277
+
278
+ it('returns false for missing html field', () => {
279
+ expect(isToolCallHtmlAppResult({ type: 'html' })).to.be.false;
280
+ });
281
+
282
+ it('returns false for null', () => {
283
+ expect(isToolCallHtmlAppResult(undefined)).to.be.false;
284
+ });
285
+
286
+ it('returns false for undefined', () => {
287
+ expect(isToolCallHtmlAppResult(undefined)).to.be.false;
288
+ });
289
+
290
+ it('returns true for full iframe-ready html app result', () => {
291
+ const html = `<!DOCTYPE html>
292
+ <html lang="en">
293
+ <head>
294
+ <meta charset="UTF-8" />
295
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
296
+ <title>Animated Unit Test Sandbox</title>
297
+ <style>
298
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #0f172a; font-family: Inter, Arial, sans-serif; }
299
+ .scene { position: relative; width: 100%; height: 100%; background: radial-gradient(circle at top, #1e293b 0%, #020617 70%); overflow: hidden; }
300
+ .grid { position: absolute; inset: 0; background-size: 40px 40px; animation: drift 12s linear infinite;
301
+ background-image: linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
302
+ linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px); }
303
+ @keyframes drift { from { transform: translateY(0px); } to { transform: translateY(40px); } }
304
+ .panel { position: absolute; top: 50%; left: 50%; width: 340px;
305
+ transform: translate(-50%, -50%); background: rgba(15, 23, 42, 0.85);
306
+ border: 1px solid rgba(255,255,255,0.1); border-radius: 20px; padding: 24px;
307
+ box-shadow: 0 0 50px rgba(59,130,246,0.3), inset 0 0 30px rgba(255,255,255,0.03);
308
+ backdrop-filter: blur(10px); }
309
+ .title { color: white; font-size: 24px; font-weight: 700; margin-bottom: 8px; }
310
+ .subtitle { color: #94a3b8; margin-bottom: 24px; }
311
+ .test { display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
312
+ color: white; font-size: 14px; opacity: 0; transform: translateX(-10px);
313
+ animation: reveal 0.5s forwards; }
314
+ .test:nth-child(3) { animation-delay: 0.2s; }
315
+ .test:nth-child(4) { animation-delay: 0.5s; }
316
+ .test:nth-child(5) { animation-delay: 0.8s; }
317
+ .test:nth-child(6) { animation-delay: 1.1s; }
318
+ @keyframes reveal { to { opacity: 1; transform: translateX(0); } }
319
+ .dot { width: 12px; height: 12px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 12px #22c55e; animation: pulse 1.5s infinite; }
320
+ .warn { background: #f59e0b; box-shadow: 0 0 12px #f59e0b; }
321
+ .fail { background: #ef4444; box-shadow: 0 0 12px #ef4444; }
322
+ @keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.4); opacity: 0.7; } }
323
+ .particle { position: absolute; border-radius: 50%; background: rgba(96,165,250,0.25); animation: float linear infinite; }
324
+ @keyframes float { from { transform: translateY(100vh) scale(0.5); } to { transform: translateY(-120px) scale(1.2); } }
325
+ .footer { margin-top: 20px; color: #64748b; font-size: 12px; text-align: center; }
326
+ </style>
327
+ </head>
328
+ <body>
329
+ <div class="scene">
330
+ <div class="grid"></div>
331
+ <div class="panel">
332
+ <div class="title">Unit Test Runner</div>
333
+ <div class="subtitle">Simulated CI Environment</div>
334
+ <div class="test"><div class="dot"></div>auth.service.spec.ts</div>
335
+ <div class="test"><div class="dot"></div>payments.integration.spec.ts</div>
336
+ <div class="test"><div class="dot warn"></div>websocket.reconnect.spec.ts</div>
337
+ <div class="test"><div class="dot fail"></div>cache.invalidation.spec.ts</div>
338
+ <div class="footer">24 passed • 1 flaky • 1 failed</div>
339
+ </div>
340
+ </div>
341
+ </body>
342
+ </html>`;
343
+ expect(isToolCallHtmlAppResult({ type: 'html', html, title: 'Animated Unit Test Sandbox' })).to.be.true;
344
+ });
345
+ });