diff --git a/packages/ai-bot/tests/prompt-construction-test.ts b/packages/ai-bot/tests/prompt-construction-test.ts index c9ae28faeeb..bd63908c008 100644 --- a/packages/ai-bot/tests/prompt-construction-test.ts +++ b/packages/ai-bot/tests/prompt-construction-test.ts @@ -41,6 +41,8 @@ import { getPromptParts, getRelevantCards, getTools, + isMarkdownSkillFile, + parseMarkdownSkill, SKILL_INSTRUCTIONS_MESSAGE, } from '@cardstack/runtime-common/ai'; import type { TextContent } from '@cardstack/runtime-common/ai/types'; @@ -7201,3 +7203,39 @@ module('fill missing capability fields from fallback constant', (hooks) => { ); }); }); + +module('markdown skills', () => { + test('isMarkdownSkillFile detects .md/.markdown by sourceUrl', (assert) => { + assert.true( + isMarkdownSkillFile({ sourceUrl: 'https://r/skills/x/SKILL.md' } as any), + ); + assert.true( + isMarkdownSkillFile({ sourceUrl: 'https://r/notes.markdown' } as any), + ); + assert.false( + isMarkdownSkillFile({ sourceUrl: 'https://r/Skill/boxel-dev' } as any), + ); + }); + + test('parseMarkdownSkill strips frontmatter and takes title from name', (assert) => { + let content = + '---\nname: "Source Code Editing"\ndescription: edits\nboxel:\n kind: skill\n---\n\n# Source Code Editing\n\nUse SEARCH/REPLACE blocks.\n'; + let { title, body } = parseMarkdownSkill(content, { + sourceUrl: 'https://r/skills/source-code-editing/SKILL.md', + } as any); + assert.strictEqual(title, 'Source Code Editing'); + assert.strictEqual( + body, + '# Source Code Editing\n\nUse SEARCH/REPLACE blocks.', + ); + assert.notOk(body.includes('kind: skill'), 'frontmatter is stripped'); + }); + + test('parseMarkdownSkill falls back to the file name when no frontmatter', (assert) => { + let { title, body } = parseMarkdownSkill('Just instructions.', { + sourceUrl: 'https://r/skills/my-skill/SKILL.md', + } as any); + assert.strictEqual(title, 'SKILL.md'); + assert.strictEqual(body, 'Just instructions.'); + }); +}); diff --git a/packages/host/app/lib/utils.ts b/packages/host/app/lib/utils.ts index 7faee7a0112..0ab62e3b641 100644 --- a/packages/host/app/lib/utils.ts +++ b/packages/host/app/lib/utils.ts @@ -86,3 +86,7 @@ export function skillCardURL(skillId: string): string { export const devSkillId = `@cardstack/skills/${devSkillLocalPath}`; export const envSkillId = `@cardstack/skills/${envSkillLocalPath}`; + +// source-code-editing now lives as a markdown SKILL.md in the skills realm and +// is enabled by URL (loaded on demand), rather than pushed as a Skill card. +export const sourceCodeEditingSkillUrl = `${skillsRealmURL}skills/source-code-editing/SKILL.md`; diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index a813af53326..b81f3701349 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -120,7 +120,11 @@ import { addPatchTools } from '../commands/utils'; import { getUniqueValidCommandDefinitions } from '../lib/command-definitions'; import { isSkillCard } from '../lib/file-def-manager'; import { getSkillSourceCommands, loadSkillSource } from '../lib/skill-commands'; -import { skillCardURL, devSkillId, envSkillId } from '../lib/utils'; +import { + devSkillId, + envSkillId, + sourceCodeEditingSkillUrl, +} from '../lib/utils'; import { importResource } from '../resources/import'; import { getRoom } from '../resources/room'; @@ -1801,11 +1805,9 @@ export default class MatrixService extends Service { async loadDefaultSkills(submode: Submode) { let interactModeDefaultSkills = [envSkillId]; - let codeModeDefaultSkills = [ - devSkillId, - envSkillId, - skillCardURL('source-code-editing'), - ]; + // source-code-editing is enabled separately as a markdown skill (see + // activateCodingSkill), so it is no longer pushed here as a card. + let codeModeDefaultSkills = [devSkillId, envSkillId]; let defaultSkills; @@ -2690,7 +2692,12 @@ export default class MatrixService extends Service { let defaultSkills = await this.loadDefaultSkills('code'); await updateRoomSkillsCommand.execute({ roomId: this.currentRoomId, - skillCardIdsToActivate: defaultSkills.map((s) => s.id), + // The card skills plus source-code-editing as a markdown skill (enabled + // by URL; UpdateRoomSkillsCommand loads .md files on demand). + skillCardIdsToActivate: [ + ...defaultSkills.map((s) => s.id), + sourceCodeEditingSkillUrl, + ], }); } diff --git a/packages/runtime-common/ai/prompt.ts b/packages/runtime-common/ai/prompt.ts index ea2e54266c7..68d5f7c28cc 100644 --- a/packages/runtime-common/ai/prompt.ts +++ b/packages/runtime-common/ai/prompt.ts @@ -436,14 +436,56 @@ async function getEnabledSkills( if (enabledSkillCards?.length) { return await Promise.all( enabledSkillCards?.map(async (cardFileDef: SerializedFileDef) => { - let cardContent = await downloadFile(client, cardFileDef); - return (JSON.parse(cardContent) as LooseSingleCardDocument)?.data; + let content = await downloadFile(client, cardFileDef); + // A markdown skill (SKILL.md) is a plain file, not a card document: + // its body is the instructions. Present it in the same card-shaped + // form the rest of the prompt pipeline expects, so skillCardsToMessages + // and getTools need no special-casing. + if (isMarkdownSkillFile(cardFileDef)) { + let { title, body } = parseMarkdownSkill(content, cardFileDef); + return { + id: cardFileDef.sourceUrl, + type: 'card', + attributes: { title, instructions: body }, + } as unknown as LooseCardResource; + } + return (JSON.parse(content) as LooseSingleCardDocument)?.data; }), ); } return []; } +// A skill enabled as a markdown file (SKILL.md), rather than a Skill card. +export function isMarkdownSkillFile(fileDef: SerializedFileDef): boolean { + return /\.(md|markdown)$/i.test(fileDef.sourceUrl ?? fileDef.name ?? ''); +} + +// Splits a markdown skill into its instruction body and a title. The body is +// everything after the leading `--- frontmatter ---` block (the frontmatter is +// metadata, not instructions); the title is the frontmatter `name`, falling +// back to the file name. +export function parseMarkdownSkill( + content: string, + fileDef: SerializedFileDef, +): { title: string; body: string } { + let body = content; + let title: string | undefined; + let match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (match) { + body = content.slice(match[0].length); + let nameLine = match[1].match(/^name:\s*(.+)$/m); + if (nameLine) { + title = nameLine[1].trim().replace(/^["']|["']$/g, ''); + } + } + if (!title) { + let path = fileDef.sourceUrl ?? fileDef.name ?? ''; + title = path.split('/').filter(Boolean).pop() ?? 'Skill'; + } + return { title, body: body.trim() }; +} + export async function getDisabledSkillIds( eventList: DiscreteMatrixEvent[], ): Promise {