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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export default class CreateListingModal extends Component<Signature> {
if (this.operatorModeStateService.workspaceChooserOpened) {
this.operatorModeStateService.closeWorkspaceChooser();
}
await this.operatorModeStateService.updateSubmode(Submodes.Code);
this.operatorModeStateService.updateSubmode(Submodes.Code);
await this.operatorModeStateService.updateCodePath(
new URL(cardUrl + '.json'),
'preview',
Expand Down
12 changes: 7 additions & 5 deletions packages/host/app/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
realmURL,
ensureTrailingSlash,
devSkillLocalPath,
envSkillLocalPath,
skillsIndexLocalPath,
} from '@cardstack/runtime-common';
export {
iconURLFor,
Expand Down Expand Up @@ -97,8 +97,10 @@ export function skillFileURL(skillName: string): string {
}

export const devSkillId = `@cardstack/skills/${devSkillLocalPath}`;
export const envSkillId = `@cardstack/skills/${envSkillLocalPath}`;

// The markdown-first source-code-editing skill, enabled directly in code
// mode alongside the card defaults.
export const sourceCodeEditingSkillUrl = `${skillsRealmURL}skills/source-code-editing/SKILL.md`;
// The skills index, the default skill for every new AI room. Spelled as a
// resolved absolute URL rather than an `@cardstack/skills/` reference because
// the index routes to its siblings entirely through document-relative markdown
// links: the prompt resolves those against the skill's own id, and only an
// absolute id can anchor that resolution.
export const skillsIndexId = `${skillsRealmURL}${skillsIndexLocalPath}`;
9 changes: 3 additions & 6 deletions packages/host/app/services/ai-assistant-panel-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,8 @@ export default class AiAssistantPanelService extends Service {
input.disabledSkillIds = disabledSkillIds;
} else {
// Use default skills (ids; may name `.md` skill files or cards)
input.enabledSkillIds = await this.matrixService.loadDefaultSkills(
this.operatorModeStateService.state.submode,
);
input.enabledSkillIds =
await this.matrixService.loadDefaultSkills();
}

({ roomId } = await createRoomCommand.execute(input));
Expand Down Expand Up @@ -538,9 +537,7 @@ export default class AiAssistantPanelService extends Service {

private async applyDefaultSkillsToRoom(roomId: string) {
try {
let skillIds = await this.matrixService.loadDefaultSkills(
this.operatorModeStateService.state.submode,
);
let skillIds = await this.matrixService.loadDefaultSkills();
if (!skillIds.length) {
return;
}
Expand Down
107 changes: 59 additions & 48 deletions packages/host/app/services/matrix-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,7 @@ import {
APP_BOXEL_SYSTEM_CARD_EVENT_TYPE,
} from '@cardstack/runtime-common/matrix-constants';

import {
type Submode,
Submodes,
} from '@cardstack/host/components/submode-switcher';
import { Submodes } from '@cardstack/host/components/submode-switcher';
import ENV from '@cardstack/host/config/environment';

import type IndexController from '@cardstack/host/controllers/index';
Expand All @@ -101,15 +98,10 @@ import { clearLocalStorage } from '@cardstack/host/utils/local-storage-keys';
import { isSkillCard } from '../lib/file-def-manager';
import { getSkillSourceTools, loadSkillSource } from '../lib/skill-tools';
import { getUniqueValidToolDefinitions } from '../lib/tool-definitions';
import {
sourceCodeEditingSkillUrl,
devSkillId,
envSkillId,
} from '../lib/utils';
import { skillsIndexId } from '../lib/utils';
import { importResource } from '../resources/import';

import { getRoom } from '../resources/room';
import UpdateRoomSkillsTool from '../tools/update-room-skills';
import { addPatchTools } from '../tools/utils';

import type CardService from './card-service';
Expand Down Expand Up @@ -1555,6 +1547,7 @@ export default class MatrixService extends Service {
// files re-upload their file content. Both contribute commands.
let skillCardsToReupload: SkillModule.Skill[] = [];
let markdownSkillFileDefs: FileDef[] = [];
let unchangedMarkdownSkillFileDefs: FileAPI.SerializedFile[] = [];
await Promise.all(
enabledSkillCardFileDefs.map(async (fileDef) => {
let source = await loadSkillSource(this.store, fileDef.sourceUrl);
Expand All @@ -1566,6 +1559,16 @@ export default class MatrixService extends Service {
);
if (isSkillCard in source) {
skillCardsToReupload.push(source as SkillModule.Skill);
} else if (
isUnchangedMarkdownSkill(source as unknown as FileDef, fileDef)
) {
// Re-uploading a `.md` skill means fetching its source over HTTP
// first. The realm-indexed file-meta already carries the hash of
// that content, so when it matches what the room recorded there
// is nothing to upload — keep the stored fileDef and skip the
// fetch. This runs on every message send, so without the check
// each send re-downloads every enabled markdown skill.
unchangedMarkdownSkillFileDefs.push(fileDef);
} else {
markdownSkillFileDefs.push(this.fileAPI.createFileDef(fileDef));
}
Expand All @@ -1577,6 +1580,26 @@ export default class MatrixService extends Service {
let enabledMarkdownSkillFileDefs = markdownSkillFileDefs.length
? await this.uploadFiles(markdownSkillFileDefs)
: [];
// Re-emit the skills in the order the room already had them. Uploading
// splits them by kind, so concatenating the two buckets would reorder
// any room holding both a skill card and a `.md` skill — a rewrite that
// changes nothing but the sequence, which still writes a new state
// event on every send. Skills that no longer load drop out, as before.
let bySourceUrl = new Map<string, FileAPI.SerializedFile>();
for (let fileDef of [
...enabledSkillFileDefs,
...enabledMarkdownSkillFileDefs,
]) {
bySourceUrl.set(fileDef.sourceUrl, fileDef.serialize());
}
for (let fileDef of unchangedMarkdownSkillFileDefs) {
bySourceUrl.set(fileDef.sourceUrl, fileDef);
}
let orderedSkillFileDefs = enabledSkillCardFileDefs
.map((fileDef) => bySourceUrl.get(fileDef.sourceUrl))
.filter((fileDef): fileDef is FileAPI.SerializedFile =>
Boolean(fileDef),
);
// get the unique subset of enabledCommandDefinitions by functionName
enabledCommandDefinitions = this.getUniqueToolDefinitions(
enabledCommandDefinitions,
Expand All @@ -1585,10 +1608,7 @@ export default class MatrixService extends Service {
enabledCommandDefinitions,
);
return {
enabledSkillCards: [
...enabledSkillFileDefs,
...enabledMarkdownSkillFileDefs,
].map((fileDef) => fileDef.serialize()),
enabledSkillCards: orderedSkillFileDefs,
disabledSkillCards: currentSkillsConfig?.disabledSkillCards ?? [],
toolDefinitions: enabledCommandDefFileDefs.map((fileDef) =>
fileDef.serialize(),
Expand Down Expand Up @@ -2130,10 +2150,13 @@ export default class MatrixService extends Service {

// The default skills for a new AI room, as skill ids. When the user's active
// system card lists any default skills — legacy `Skill` cards, `.md` skill
// files, or both — those win (mode-agnostic). Otherwise we fall back to the
// hardcoded, submode-aware set. Ids may name a `.md` skill file or a legacy
// `Skill` card; callers resolve them kind-agnostically via `loadSkillSource`.
async loadDefaultSkills(submode: Submode): Promise<string[]> {
// files, or both — those win. Otherwise the room gets the skills index, whose
// body names everything the model can then pull on demand. Either way the set
// does not depend on the submode: the index covers coding and runtime work
// alike, so entering code mode needs no second activation pass. Ids may name
// a `.md` skill file or a legacy `Skill` card; callers resolve them
// kind-agnostically via `loadSkillSource`.
async loadDefaultSkills(): Promise<string[]> {
let configuredIds = [
...(this.systemCard?.defaultSkillCards ?? []),
...(this.systemCard?.defaultSkillFiles ?? []),
Expand All @@ -2144,18 +2167,7 @@ export default class MatrixService extends Service {
return configuredIds;
}

let interactModeDefaultSkills = [envSkillId];

// Code editing is covered by the code-mode entry-point skill (see
// activateCodingSkill), so source-code-editing is no longer pushed here.
// The two remaining defaults are still legacy pushed cards (full body in
// every prompt); they move to markdown + on-demand references once the
// bot supports commands on markdown skills, after which this list shrinks.
let codeModeDefaultSkills = [devSkillId, envSkillId];

return submode === 'code'
? codeModeDefaultSkills
: interactModeDefaultSkills;
return [skillsIndexId];
}

@cached
Expand Down Expand Up @@ -3053,24 +3065,6 @@ export default class MatrixService extends Service {
this.localPersistenceService.setCurrentRoomId(undefined);
}

async activateCodingSkill() {
if (!this.currentRoomId) {
return;
}

let updateRoomSkillsCommand = new UpdateRoomSkillsTool(
this.toolService.toolContext,
);
let defaultSkillIds = await this.loadDefaultSkills('code');
await updateRoomSkillsCommand.execute({
roomId: this.currentRoomId,
// Dual-path window: the legacy card skills activate alongside the
// markdown source-code-editing skill. All are pushed for now; the
// on-demand entry point returns as a catalog listing.
skillCardIdsToActivate: [...defaultSkillIds, sourceCodeEditingSkillUrl],
});
}

loadMoreAIRooms() {
this.loadMoreAIRoomsTask.perform();
}
Expand Down Expand Up @@ -3455,6 +3449,23 @@ async function getStorage() {
return storage;
}

// True when a `.md` skill's realm-indexed content matches what the room
// already recorded, so the stored fileDef can be reused as-is. Both hashes come
// from the same content, one via the realm's index and one from the upload that
// wrote the room's copy, so equality means an upload would produce the same
// bytes. A stored def with no uploaded `url` is never reusable.
function isUnchangedMarkdownSkill(
source: FileDef,
stored: FileAPI.SerializedFile,
): boolean {
return Boolean(
source.contentHash &&
stored.contentHash &&
source.contentHash === stored.contentHash &&
stored.url,
);
}

function serializeFileForPersistence(
f: ReturnType<FileDef['serialize']>,
): StoredPendingFile {
Expand Down
6 changes: 1 addition & 5 deletions packages/host/app/services/operator-mode-state-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,13 +749,9 @@ export default class OperatorModeStateService extends Service {
return this.schedulePersist();
}

async updateSubmode(submode: Submode) {
updateSubmode(submode: Submode) {
this._state.submode = submode;
this.schedulePersist();

if (submode === Submodes.Code) {
await this.matrixService.activateCodingSkill();
}
}

async updateModuleInspectorView(view: ModuleInspectorView) {
Expand Down
2 changes: 1 addition & 1 deletion packages/host/app/tools/show-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default class ShowFileTool extends HostBaseTool<
await operatorModeStateService.updateCodePath(
new URL(input.fileIdentifier),
);
await operatorModeStateService.updateSubmode('code');
operatorModeStateService.updateSubmode('code');
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/host/app/tools/switch-submode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export default class SwitchSubmodeTool extends HostBaseTool<
throw new Error(`invalid submode specified: ${input.submode}`);
}

await this.operatorModeStateService.updateSubmode(input.submode);
this.operatorModeStateService.updateSubmode(input.submode);
if (this.operatorModeStateService.workspaceChooserOpened) {
this.operatorModeStateService.closeWorkspaceChooser();
}
Expand Down
8 changes: 5 additions & 3 deletions packages/host/tests/acceptance/ai-assistant-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
type TestContextWithSave,
delay,
getMonacoContent,
envSkillId,
skillsIndexId,
catalogRealmURL,
realmConfigCardJSON,
} from '../helpers';
Expand Down Expand Up @@ -3072,15 +3072,17 @@ module('Acceptance | AI Assistant tests', function (hooks) {
).map((el) => el.getAttribute('data-test-attached-card'));
assert.ok(
false,
`Default skill never rendered on the new session's skill menu. Attached cards seen: ${JSON.stringify(attached)}; expected exactly [${envSkillId}].`,
`Default skill never rendered on the new session's skill menu. Attached cards seen: ${JSON.stringify(attached)}; expected exactly [${skillsIndexId}].`,
);
return;
}
assert
.dom('[data-test-skill-menu] [data-test-attached-card]')
.exists({ count: 1 });
assert
.dom(`[data-test-skill-menu] [data-test-attached-card="${envSkillId}"]`)
.dom(
`[data-test-skill-menu] [data-test-attached-card="${skillsIndexId}"]`,
)
.exists();
});

Expand Down
Loading
Loading