From 17b68fe8238ec7e6c8caec36f915014a9243e63b Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 3 Jul 2026 10:59:51 +0200 Subject: [PATCH 1/2] ai-bot: render markdown skills in the prompt (CS-11555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getEnabledSkills assumed every enabled skill was a Skill card and JSON-parsed its downloaded content, so a skill enabled as a SKILL.md file broke prompt assembly. Detect markdown skills by file extension and present them in the same card-shaped form the pipeline already expects — title from the frontmatter name (falling back to the file name), instructions from the body after the frontmatter block. skillCardsToMessages and getTools need no changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-bot/tests/prompt-construction-test.ts | 38 +++++++++++++++ packages/runtime-common/ai/prompt.ts | 46 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) 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/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 { From e4d81c2e277bb591a1374e09935b9ecfd06421c1 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 3 Jul 2026 11:12:41 +0200 Subject: [PATCH 2/2] Enable source-code-editing as a markdown skill in code mode (CS-11555) Stop pushing source-code-editing as a Skill card in code mode; enable it by URL as the markdown SKILL.md in the skills realm instead. UpdateRoomSkillsCommand already loads .md skills, and the bot now renders them, so its instructions flow the same way while the source of truth moves to the markdown file. The other two code-mode skills stay as cards for now. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/lib/utils.ts | 4 ++++ packages/host/app/services/matrix-service.ts | 21 +++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) 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, + ], }); }