Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/skill-search-bm25.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add BM25-based skill search for large catalogues. When >80 skills are installed, the system prompt switches from a full listing to a compact name-only format and the model discovers skills via the Skill tool's new `action: "search"` endpoint. Startup memory reduced ~95% via lazy content loading.
7 changes: 5 additions & 2 deletions packages/agent-core/src/profile/default/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,12 @@ Skills are grouped by scope (`Project`, `User`, `Extra`, `Built-in`) so you can

## How to use skills

Identify the skills that are likely to be useful for the tasks you are currently working on, read the skill file for detailed instructions, guidelines, scripts and more.
When you need a skill, follow this two-step process:

Only read skill details when needed to conserve the context window.
1. **Search**: Call the `Skill` tool with `action: "search"` and relevant keywords to find matching skills. The search returns ranked results instantly.
2. **Load**: Once you identify the right skill from search results, call the `Skill` tool with `action: "load"` and the skill name to load its full instructions into context.

Only read skill details when needed to conserve the context window. Do NOT guess skill names — always search first when the skill listing above does not contain enough detail.

# Ultimate Reminders

Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './builtin';
export * from './parser';
export * from './registry';
export * from './scanner';
export * from './search';
export * from './types';
58 changes: 58 additions & 0 deletions packages/agent-core/src/skill/parser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createReadStream } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'pathe';

Expand All @@ -8,6 +9,13 @@ import type { SkillDefinition, SkillMetadata, SkillSource } from './types';
import { isSupportedSkillType } from './types';
import { escapeXmlTags } from '../utils/xml-escape';

/**
* Sentinel stored in SkillDefinition.content when only frontmatter was
* parsed at startup. renderSkillPrompt() checks for this to decide
* whether to lazy-load the full body from disk.
*/
export const LAZY_CONTENT_SENTINEL = '\u0000LAZY';

export class FrontmatterError extends Error {
constructor(message: string, cause?: unknown) {
super(message);
Expand Down Expand Up @@ -79,6 +87,56 @@ export async function parseSkillFromFile(options: ParseSkillOptions): Promise<Sk
return parseSkillText({ ...options, text });
}

/**
* Read only the frontmatter from a SKILL.md file, leaving `content` empty.
* The body is not read from disk — callers can load it later via
* `readFile` + `parseSkillText` when the full content is actually needed.
*
* This avoids loading the full body of thousands of SKILL files into memory
* at startup when only the index (name, description) is needed.
*/
export async function parseSkillMetaFromFile(options: ParseSkillOptions): Promise<SkillDefinition> {
const stream = createReadStream(options.skillMdPath, { encoding: 'utf8', highWaterMark: 4096 });
let buffer = '';
let fenceCount = 0;

try {
for await (const chunk of stream) {
buffer += chunk;
const fences = buffer.match(/^---\s*$/gm);
if (fences !== null && fences.length >= 2) {
fenceCount = 2;
break;
}
}
} finally {
stream.close();
}

if (fenceCount < 2) {
return parseSkillFromFile(options);
}

// M1 fix: find second fence in the original buffer to handle CRLF correctly.
// split(/\r?\n/) strips \r\n as one separator but offset counting must
// account for the original byte positions.
let fencesFound = 0;
let offset = 0;
const lines = buffer.split('\n');
for (const line of lines) {
const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line;
if (/^---\s*$/.test(trimmed)) {
fencesFound++;
if (fencesFound === 2) break;
}
offset += line.length + 1; // +1 for the \n that split removed
}

const frontmatterOnly = buffer.slice(0, offset + 3);
const result = parseSkillText({ ...options, text: frontmatterOnly });
return { ...result, content: LAZY_CONTENT_SENTINEL };
}

export function parseFrontmatter(text: string): ParsedFrontmatter {
const lines = text.split(/\r?\n/);
if (lines[0]?.trim() !== FENCE) {
Expand Down
103 changes: 94 additions & 9 deletions packages/agent-core/src/skill/registry.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { expandSkillParameters, skillArgumentNames } from './parser';
import { readFileSync } from 'node:fs';

import { LAZY_CONTENT_SENTINEL, expandSkillParameters, skillArgumentNames, parseSkillMetaFromFile, parseSkillText } from './parser';
import { discoverSkills, type DiscoverSkillsOptions } from './scanner';
import { SkillSearchIndex, type SkillSearchResult } from './search';
import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types';
import { isInlineSkillType, normalizeSkillName } from './types';
import { escapeXmlAttr } from '../utils/xml-escape';

const LISTING_DESC_MAX = 250;

/**
* Above this threshold, getModelSkillListing() switches to a compact
* name-only listing and tells the model to use the `skill_search` tool.
* Below it, the legacy full listing is injected into the system prompt
* (cheaper for prompt caching with small catalogues).
*/
const COMPACT_LISTING_THRESHOLD = 80;

/**
* Above this threshold, the compact listing drops descriptions entirely
* and lists only skill names.
*/
const NAMES_ONLY_LISTING_THRESHOLD = 300;

export class SkillNotFoundError extends Error {
readonly skillName: string;

Expand All @@ -30,6 +47,9 @@ export class SkillRegistry {
private readonly discoverImpl: typeof discoverSkills;
private readonly onWarning: (message: string, cause?: unknown) => void;
readonly sessionId?: string;
private readonly searchIndex = new SkillSearchIndex();

private indexDirty = false;

constructor(options: SkillRegistryOptions = {}) {
this.discoverImpl = options.discover ?? discoverSkills;
Expand All @@ -42,8 +62,13 @@ export class SkillRegistry {
if (!this.roots.includes(root.path)) this.roots.push(root.path);
}

// Only parse frontmatter at startup (name, description, whenToUse).
// The full body is loaded on demand when renderSkillPrompt() is called.
// This saves ~95% memory for large skill catalogues.

const skills = await this.discoverImpl({
roots,
parse: parseSkillMetaFromFile,
onWarning: this.onWarning,
onSkippedByPolicy: (skill) => this.skipped.push(skill),
onDiscoveredSkill: (skill) => {
Expand All @@ -54,6 +79,10 @@ export class SkillRegistry {
for (const skill of skills) {
this.byName.set(normalizeSkillName(skill.name), skill);
}

// Build the BM25 search index so the model can discover skills
// via the `skill_search` tool instead of scanning a full listing.
this.searchIndex.build(this.listInvocableSkills());
}

registerBuiltinSkill(skill: SkillDefinition): void {
Expand All @@ -64,6 +93,7 @@ export class SkillRegistry {
const key = normalizeSkillName(skill.name);
if (options.replace === true || !this.byName.has(key)) {
this.byName.set(key, skill);
this.indexDirty = true;
}
this.indexPluginSkill(skill, options);
}
Expand All @@ -88,8 +118,22 @@ export class SkillRegistry {
}

renderSkillPrompt(skill: SkillDefinition, rawArgs: string): string {
// Lazy content loading: when compact mode parsed only frontmatter,
// the body is empty. Read the full file now (sync, only for activated skills).
let content = skill.content;
if (content === LAZY_CONTENT_SENTINEL && skill.path.length > 0) {
const text = readFileSync(skill.path, 'utf8');
const full = parseSkillText({
skillMdPath: skill.path,
skillDirName: skill.dir.split('/').pop() ?? skill.dir,
source: skill.source,
text,
});
content = full.content;
}

const argumentNames = skillArgumentNames(skill.metadata);
const content = expandSkillParameters(skill.content, rawArgs, {
content = expandSkillParameters(content, rawArgs, {
skillDir: skill.dir,
sessionId: this.sessionId,
argumentNames,
Expand Down Expand Up @@ -129,16 +173,47 @@ export class SkillRegistry {
return rendered.length === 0 ? 'No skills' : rendered;
}

/**
* Search skills by free-text query. Delegates to the BM25 index.
* Lazily rebuilds the index if skills were registered since the last build.
*/
searchSkills(query: string, limit?: number): readonly SkillSearchResult[] {
if (this.indexDirty) {
this.searchIndex.build(this.listInvocableSkills());
this.indexDirty = false;
}
return this.searchIndex.search(query, limit);
}

getModelSkillListing(): string {
const lines = ['DISREGARD any earlier skill listings. Current available skills:'];
const listing = renderGroupedSkills(
this.listInvocableSkills().filter((skill) => skill.metadata.isSubSkill !== true),
formatModelSkill,
const invocable = this.listInvocableSkills().filter(
(skill) => skill.metadata.isSubSkill !== true,
);
if (listing.length > 0) {
lines.push(listing);

// Auto-detect: small catalogue → legacy full listing.
// Large catalogue → compact/names-only + search-first.
if (invocable.length <= COMPACT_LISTING_THRESHOLD) {
const lines = ['DISREGARD any earlier skill listings. Current available skills:'];
const listing = renderGroupedSkills(invocable, formatModelSkill);
if (listing.length > 0) lines.push(listing);
return lines.length === 1 ? '' : lines.join('\n');
}
return lines.length === 1 ? '' : lines.join('\n');

// Tier 2+3: Large catalogue — search-first.
const count = invocable.length;
const format = count > NAMES_ONLY_LISTING_THRESHOLD
? formatNameOnlySkill
: formatCompactSkill;
const lines = [
`You have access to ${String(count)} registered skills.`,
'To find relevant skills, call the `Skill` tool with `action: "search"` and keywords from the user\'s request.',
'Do NOT guess skill names — always search first, then load with `action: "load"`.',
'',
'Skill names by scope:',
];
const listing = renderGroupedSkills(invocable, format);
if (listing.length > 0) lines.push(listing);
return lines.join('\n');
}
}

Expand Down Expand Up @@ -182,6 +257,16 @@ function formatModelSkill(skill: SkillDefinition): readonly string[] {
return lines;
}

/** Compact format: name + 80-char description, no path. */
function formatCompactSkill(skill: SkillDefinition): readonly string[] {
return [`- ${skill.name}: ${truncate(skill.description, 80)}`];
}

/** Minimal format: name only. Used for catalogues > 200 skills. */
function formatNameOnlySkill(skill: SkillDefinition): readonly string[] {
return [`- ${skill.name}`];
}

function truncate(value: string, max: number): string {
return value.length > max ? value.slice(0, max) : value;
}
Loading