Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4eca853
feat(skill): BM25-based smart skill retrieval for large catalogues
claudianus Jun 13, 2026
45a94e4
fix: address Codex review comments
claudianus Jun 13, 2026
b1136f9
test: add smoke tests exercising full model flow
claudianus Jun 13, 2026
d3be03a
feat(agent-core): improve skill search with field boosting, synonyms,…
claudianus Jun 15, 2026
0508acc
Merge branch 'upstream/main' into pr-726-resolve
claudianus Jun 15, 2026
93f52f6
fix(type): expose searchSkills on SkillRegistry interface for upstrea…
claudianus Jun 15, 2026
165a197
docs: correct names-only tier threshold comment (300, not 200)
claudianus Jun 15, 2026
a6c632b
test(skill-tool): cover search action execution path
claudianus Jun 15, 2026
4891eb9
fix(agent-core): address Codex review comments on skill search
claudianus Jun 16, 2026
2290fbc
feat(skill-search): support Unicode tokenization for non-English skills
claudianus Jun 16, 2026
f9b8fda
feat(skill-search): add optional minScore threshold to filter low-rel…
claudianus Jun 16, 2026
6057afd
feat(skill-search): index a small body snippet to improve recall
claudianus Jun 16, 2026
25577a2
feat(skill-search): use skill aliases and tags as weighted search terms
claudianus Jun 16, 2026
338d6ba
test(skill-search): add dedicated SkillSearchIndex unit tests
claudianus Jun 16, 2026
699f4e5
feat(skill-registry): make listing tier thresholds configurable
claudianus Jun 16, 2026
f357f22
feat(skill-parser): derive flat-skill description from first sentence
claudianus Jun 16, 2026
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.
2 changes: 2 additions & 0 deletions packages/agent-core/src/agent/skill/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SkillDefinition } from '../../skill';
import type { SkillSearchResult } from '../../skill/search';

export interface SkillRegistry {
getSkill(name: string): SkillDefinition | undefined;
Expand All @@ -7,4 +8,5 @@ export interface SkillRegistry {
listInvocableSkills(): readonly SkillDefinition[];
getSkillRoots(): readonly string[];
getModelSkillListing(): string;
searchSkills(query: string, limit?: number): readonly SkillSearchResult[];
}
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 @@ -132,9 +132,12 @@ Skills are modular extensions that provide:

## 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.

## Available skills

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';
90 changes: 84 additions & 6 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,75 @@ 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);
}

// Find the exact end of the second fence in the original buffer so the
// slice works for both LF and CRLF line endings. `split('\n')` keeps a
// trailing '\r' on CRLF lines, so the fence line itself is '---\r' (length
// 4) and must be included in full; hard-coding +3 only works for LF.
let fencesFound = 0;
let offset = 0;
let fenceLineLength = 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) {
fenceLineLength = line.length;
break;
}
}
offset += line.length + 1; // +1 for the \n that split removed
}

const frontmatterOnly = buffer.slice(0, offset + fenceLineLength);
const bodyStart = offset + fenceLineLength;
const bodySnippet = buffer.slice(bodyStart, bodyStart + 1024).trim() || undefined;

const result = parseSkillText({ ...options, text: frontmatterOnly });
const definition = { ...result, content: LAZY_CONTENT_SENTINEL, bodySnippet };

// Flat .md skills are allowed to omit description and derive it from the
// first body line. The frontmatter-only parse has no body, so re-parse the
// full file when that fallback was triggered.
const isDirectorySkill = path.basename(options.skillMdPath) === 'SKILL.md';
if (!isDirectorySkill && definition.description === 'No description provided.') {
const full = await parseSkillFromFile(options);
return { ...definition, description: full.description };
}

return definition;
}

export function parseFrontmatter(text: string): ParsedFrontmatter {
const lines = text.split(/\r?\n/);
if (lines[0]?.trim() !== FENCE) {
Expand Down Expand Up @@ -242,12 +319,13 @@ function normalizeMetadata(raw: Record<string, unknown>): SkillMetadata {
}

function descriptionFromBody(body: string): string {
const firstLine = body
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (firstLine === undefined) return 'No description provided.';
return firstLine.length > 240 ? `${firstLine.slice(0, 239)}…` : firstLine;
const lines = body.split(/\r?\n/).map((line) => line.trim());
const firstSentence = lines
.join(' ')
.match(/[^.!?]+[.!?]+/);
const candidate = firstSentence?.[0]?.trim() ?? lines.find((line) => line.length > 0);
if (candidate === undefined) return 'No description provided.';
return candidate.length > 240 ? `${candidate.slice(0, 239)}…` : candidate;
}

function tokenizeArgs(raw: string): string[] {
Expand Down
131 changes: 122 additions & 9 deletions packages/agent-core/src/skill/registry.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
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 type { SkillRegistry as AgentSkillRegistry } from '../agent/skill/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 @@ -21,6 +38,8 @@ export interface SkillRegistryOptions {
readonly discover?: typeof discoverSkills;
readonly onWarning?: (message: string, cause?: unknown) => void;
readonly sessionId?: string;
readonly compactListingThreshold?: number;
readonly namesOnlyListingThreshold?: number;
}

export class SessionSkillRegistry implements AgentSkillRegistry {
Expand All @@ -31,20 +50,34 @@ export class SessionSkillRegistry implements AgentSkillRegistry {
private readonly discoverImpl: typeof discoverSkills;
private readonly onWarning: (message: string, cause?: unknown) => void;
readonly sessionId?: string;
private readonly searchIndex = new SkillSearchIndex();
private readonly compactListingThreshold: number;
private readonly namesOnlyListingThreshold: number;

private indexDirty = false;
private modelSkillListingCache: string | undefined;

constructor(options: SkillRegistryOptions = {}) {
this.discoverImpl = options.discover ?? discoverSkills;
this.onWarning = options.onWarning ?? (() => {});
this.sessionId = options.sessionId;
this.compactListingThreshold = options.compactListingThreshold ?? COMPACT_LISTING_THRESHOLD;
this.namesOnlyListingThreshold = options.namesOnlyListingThreshold ?? NAMES_ONLY_LISTING_THRESHOLD;
}

async loadRoots(roots: readonly SkillRoot[]): Promise<void> {
this.modelSkillListingCache = undefined;
for (const root of roots) {
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 @@ -55,6 +88,12 @@ export class SessionSkillRegistry implements AgentSkillRegistry {
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.
// Sub-skills are excluded: they are intentionally hidden from the model
// and reachable only through their parent skill.
this.searchIndex.build(this.listSearchableSkills());
}

registerBuiltinSkill(skill: SkillDefinition): void {
Expand All @@ -65,6 +104,8 @@ export class SessionSkillRegistry implements AgentSkillRegistry {
const key = normalizeSkillName(skill.name);
if (options.replace === true || !this.byName.has(key)) {
this.byName.set(key, skill);
this.indexDirty = true;
this.modelSkillListingCache = undefined;
}
this.indexPluginSkill(skill, options);
}
Expand All @@ -89,8 +130,22 @@ export class SessionSkillRegistry implements AgentSkillRegistry {
}

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 All @@ -117,6 +172,15 @@ export class SessionSkillRegistry implements AgentSkillRegistry {
);
}

/**
* Skills that should be discoverable by the model via search or the skill
* listing. Same as {@link listInvocableSkills} but excludes sub-skills, which
* are hidden from the model and should only be reached through their parent.
*/
private listSearchableSkills(): readonly SkillDefinition[] {
return this.listInvocableSkills().filter((skill) => skill.metadata.isSubSkill !== true);
}

getSkillRoots(): readonly string[] {
return [...this.roots];
}
Expand All @@ -130,16 +194,55 @@ export class SessionSkillRegistry implements AgentSkillRegistry {
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, minScore?: number): readonly SkillSearchResult[] {
if (this.indexDirty) {
this.searchIndex.build(this.listSearchableSkills());
this.indexDirty = false;
}
return this.searchIndex.search(query, limit, minScore);
}

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

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.
let listing: string;
if (invocable.length <= this.compactListingThreshold) {
const lines = ['DISREGARD any earlier skill listings. Current available skills:'];
const rendered = renderGroupedSkills(invocable, formatModelSkill);
if (rendered.length > 0) lines.push(rendered);
listing = lines.length === 1 ? '' : lines.join('\n');
} else {
// Tier 2+3: Large catalogue — search-first.
const count = invocable.length;
const format = count > this.namesOnlyListingThreshold
? 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 rendered = renderGroupedSkills(invocable, format);
if (rendered.length > 0) lines.push(rendered);
listing = lines.join('\n');
}
return lines.length === 1 ? '' : lines.join('\n');

this.modelSkillListingCache = listing;
return listing;
}
}

Expand Down Expand Up @@ -183,6 +286,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 > 300 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