From c17ae49231c0c3d944538eda0af1c6806d7f9690 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 18 Jun 2026 16:36:43 +0800 Subject: [PATCH 1/2] fix(skill): dedupe skills with identical SKILL.md content - hash SKILL.md content with sha256 during discovery - skip re-registering a skill whose content was already seen - prefer the sub-skill form when a duplicate collides with a parent copy --- packages/agent-core/src/skill/scanner.ts | 21 ++++++++++++++ .../agent-core/test/skill/scanner.test.ts | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/agent-core/src/skill/scanner.ts b/packages/agent-core/src/skill/scanner.ts index 6c1ddb462..925ec24c7 100644 --- a/packages/agent-core/src/skill/scanner.ts +++ b/packages/agent-core/src/skill/scanner.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'pathe'; @@ -139,6 +140,7 @@ export async function discoverSkills( const warn = options.onWarning ?? (() => {}); const skip = options.onSkippedByPolicy ?? (() => {}); const byName = new Map(); + const byContent = new Map(); async function walkSkillDir( dirPath: string, @@ -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, @@ -202,6 +205,7 @@ export async function discoverSkills( await parseAndRegister({ parse, byName, + byContent, skillMdPath: rootSkillMd, skillDirName: path.basename(dirPath), root, @@ -227,6 +231,7 @@ export async function discoverSkills( await parseAndRegister({ parse, byName, + byContent, skillMdPath, skillDirName: skillName, root, @@ -362,6 +367,7 @@ async function pushProvidedRoot( async function parseAndRegister(input: { readonly parse: NonNullable; readonly byName: Map; + readonly byContent: Map; readonly skillMdPath: string; readonly skillDirName: string; readonly root: SkillRoot; @@ -371,6 +377,9 @@ async function parseAndRegister(input: { readonly subSkillParentName?: string; }): Promise { try { + const raw = await fs.readFile(input.skillMdPath, 'utf8'); + const contentHash = hashSkillContent(raw); + const parsed = await input.parse({ skillMdPath: input.skillMdPath, skillDirName: input.skillDirName, @@ -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; + input.byName.delete(normalizeSkillName(existing.name)); + } + input.byContent.set(contentHash, discovered); input.onDiscoveredSkill?.(discovered); const key = normalizeSkillName(discovered.name); if (!input.byName.has(key)) { @@ -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}`; diff --git a/packages/agent-core/test/skill/scanner.test.ts b/packages/agent-core/test/skill/scanner.test.ts index 8274f0056..4a9adf976 100644 --- a/packages/agent-core/test/skill/scanner.test.ts +++ b/packages/agent-core/test/skill/scanner.test.ts @@ -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'); From 30d87e23e9271c196eca1c2fbb050a5736d30ce3 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 18 Jun 2026 16:38:25 +0800 Subject: [PATCH 2/2] chore: add changeset for duplicate skill dedupe fix --- .changeset/dedupe-identical-skills.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dedupe-identical-skills.md diff --git a/.changeset/dedupe-identical-skills.md b/.changeset/dedupe-identical-skills.md new file mode 100644 index 000000000..d83c7b768 --- /dev/null +++ b/.changeset/dedupe-identical-skills.md @@ -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.