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
38 changes: 38 additions & 0 deletions packages/ai-bot/tests/prompt-construction-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.');
});
});
4 changes: 4 additions & 0 deletions packages/host/app/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
21 changes: 14 additions & 7 deletions packages/host/app/services/matrix-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
],
});
}

Expand Down
46 changes: 44 additions & 2 deletions packages/runtime-common/ai/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
Expand Down
Loading