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/dedupe-identical-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix duplicate skills appearing when a sub-skill is also shipped as a flattened copy with identical content.
21 changes: 21 additions & 0 deletions packages/agent-core/src/skill/scanner.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'pathe';

Expand Down Expand Up @@ -139,6 +140,7 @@ export async function discoverSkills(
const warn = options.onWarning ?? (() => {});
const skip = options.onSkippedByPolicy ?? (() => {});
const byName = new Map<string, SkillDefinition>();
const byContent = new Map<string, SkillDefinition>();

async function walkSkillDir(
dirPath: string,
Expand Down Expand Up @@ -177,6 +179,7 @@ export async function discoverSkills(
const skill = await parseAndRegister({
parse,
byName,
byContent,
skillMdPath: path.join(dirPath, entry, 'SKILL.md'),
skillDirName: entry,
root,
Expand All @@ -202,6 +205,7 @@ export async function discoverSkills(
await parseAndRegister({
parse,
byName,
byContent,
skillMdPath: rootSkillMd,
skillDirName: path.basename(dirPath),
root,
Expand All @@ -227,6 +231,7 @@ export async function discoverSkills(
await parseAndRegister({
parse,
byName,
byContent,
skillMdPath,
skillDirName: skillName,
root,
Expand Down Expand Up @@ -362,6 +367,7 @@ async function pushProvidedRoot(
async function parseAndRegister(input: {
readonly parse: NonNullable<DiscoverSkillsOptions['parse']>;
readonly byName: Map<string, SkillDefinition>;
readonly byContent: Map<string, SkillDefinition>;
readonly skillMdPath: string;
readonly skillDirName: string;
readonly root: SkillRoot;
Expand All @@ -371,6 +377,9 @@ async function parseAndRegister(input: {
readonly subSkillParentName?: string;
}): Promise<SkillDefinition | undefined> {
try {
const raw = await fs.readFile(input.skillMdPath, 'utf8');
const contentHash = hashSkillContent(raw);

const parsed = await input.parse({
skillMdPath: input.skillMdPath,
skillDirName: input.skillDirName,
Expand All @@ -392,6 +401,14 @@ async function parseAndRegister(input: {
...skill,
plugin: input.root.plugin,
};
const existing = input.byContent.get(contentHash);
if (existing !== undefined) {
const preferCurrent =
discovered.metadata.isSubSkill === true && existing.metadata.isSubSkill !== true;
if (!preferCurrent) return undefined;
Comment on lines +404 to +408

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not dedupe every identical content hash

When two distinct skills happen to have byte-identical markdown, this returns before onDiscoveredSkill and before adding the second name. That drops legitimate cases such as foo.md and bar.md with the same body but different filename-derived names, and it also prevents SessionSkillRegistry.loadRoots from indexing the second plugin's copy for getPluginSkill when two enabled plugins vendor the same startup SKILL.md; those are not flattened sub-skill duplicates and should remain addressable.

Useful? React with 👍 / 👎.

input.byName.delete(normalizeSkillName(existing.name));
}
input.byContent.set(contentHash, discovered);
input.onDiscoveredSkill?.(discovered);
const key = normalizeSkillName(discovered.name);
if (!input.byName.has(key)) {
Expand All @@ -414,6 +431,10 @@ async function parseAndRegister(input: {
}
}

function hashSkillContent(content: string): string {
return createHash('sha256').update(content).digest('hex');
}

function qualifySubSkillName(parentName: string, skillName: string): string {
if (skillName === parentName || skillName.startsWith(`${parentName}.`)) return skillName;
return `${parentName}.${skillName}`;
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-core/test/skill/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,34 @@ describe('discoverSkills shape and ordering', () => {
expect(skills.find((s) => s.name === 'outer.inner')?.metadata.isSubSkill).toBe(true);
});

it('dedupes identical SKILL.md files discovered via flattened subskill copies', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
const childContent = [
'---',
'name: legacy-child',
'description: Nested skill',
'---',
'',
'Child body.',
];
await writeSkill(root, path.join('outer', 'SKILL.md'), [
'---',
'name: outer',
'description: Parent skill',
'has-sub-skill: true',
'---',
'',
'Outer body.',
]);
await writeSkill(root, path.join('outer', 'child', 'SKILL.md'), childContent);
await writeSkill(root, path.join('outer__child', 'SKILL.md'), childContent);

const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });

expect(skills.map((s) => s.name)).toEqual(['outer', 'outer.legacy-child']);
});

it('does not discover nested SKILL.md files when the parent bundle disables sub-skills', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
Expand Down
Loading