diff --git a/CHANGELOG.md b/CHANGELOG.md index f988f76570..84f94fbda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [v5.3.0] - 2026-01-29 + +### Added + +- New skill tool functionality for enhanced code assistance +- Skill parser and type definitions for skill management +- Comprehensive test coverage for skill tool components +- Enhanced code index orchestrator with skill integration +- State manager for skill tool operations + +### Changed + +- Updated tool type definitions to support skill tools +- Enhanced assistant message presentation with skill support +- Improved code index manager with skill-related functionality +- Updated dependencies in package.json + +--- + ## [v5.2.5] - 2026-01-21 ### Added diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 49c92f20b9..f3a0077ea5 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -45,6 +45,7 @@ export const toolNames = [ "run_slash_command", "generate_image", "check_past_chat_memories", + "use_skill", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 03a1e6c28b..fb7f8dcc00 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -33,6 +33,7 @@ import { generateImageTool } from "../tools/generateImageTool" import { planFileEditTool } from "../tools/planFileEditTool" import { runSlashCommandTool } from "../tools/runSlashCommandTool" import { updateTodoListTool } from "../tools/updateTodoListTool" +import { useSkillTool } from "../tools/useSkillTool" import Anthropic from "@anthropic-ai/sdk" // kilocode_change import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" @@ -626,6 +627,9 @@ export async function presentAssistantMessage(cline: Task) { case "plan_file_edit": await planFileEditTool(cline, block, handleError, pushToolResult, removeClosingTag) break + case "use_skill": + await useSkillTool(cline, block, handleError, pushToolResult) + break } break diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index b916325467..25bce28dac 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -25,6 +25,7 @@ import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system- import { type ClineProviderState } from "../webview/ClineProvider" // kilocode_change import { addCustomInstructions, getMcpServersSection, getSystemInfoSection } from "./sections" import { getToolDescriptionsForMode } from "./tools" +import { discoverSkills } from "../tools/skills" // Helper function to get prompt component, filtering out empty objects export function getPromptComponent( @@ -60,6 +61,26 @@ function getPreviousChatTitlesSection(history?: HistoryItem[]): string { return `Previous Chat Titles: ${titles.join(", ")}` } +/** + * Get available skills section for system prompt + */ +async function getSkillsSection(workspacePath: string): Promise { + const skills = await discoverSkills({ workspacePath }) + + if (skills.length === 0) { + return "" + } + + const skillList = skills + .map((skill) => { + return ` - ${skill.metadata.name}: ${skill.metadata.description}` + }) + .join("\n") + + return `You are provided Skills below, these skills are to be used by you as per your descretion. The purpose of these skills is to provide you additional niche context for you tasks. You might get skills for React, Security or even third-party tools. Use the tool use_skill to get the skill context: +${skillList}` +} + const applyDiffToolDescription = ` Common tool calls and explanations @@ -464,11 +485,12 @@ async function generatePrompt( const hasMcpServers = mcpHub && mcpHub.getServers().length > 0 const shouldIncludeMcp = hasMcpGroup && hasMcpServers - const [mcpServersSection] = await Promise.all([ + const [mcpServersSection, skillsSection] = await Promise.all([ // getModesSection(context, toolUseStyle /*kilocode_change*/), shouldIncludeMcp ? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation) : Promise.resolve(""), + getSkillsSection(cwd), ]) const codeIndexManager = CodeIndexManager.getInstance(context, cwd) @@ -502,6 +524,8 @@ ${applyDiffToolDescription} ${mcpServersSection} +${skillsSection} + ${previousChatTitlesSection} ${getSystemInfoSection(cwd)} diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 9cc0ec8bc8..ccd92c3199 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -33,6 +33,7 @@ import { getUpdateTodoListDescription } from "./update-todo-list" import { getRunSlashCommandDescription } from "./run-slash-command" import { getGenerateImageDescription } from "./generate-image" import { getCheckPastChatMemoriesDescription } from "./check-past-chat-memories" +import { getUseSkillDescription } from "./use-skill" import { CodeIndexManager } from "../../../services/code-index/manager" // kilocode_change start: Morph fast apply @@ -42,7 +43,7 @@ import { type ClineProviderState } from "../../webview/ClineProvider" // kilocode_change end // Map of tool names to their description functions -const toolDescriptionMap: Record string | undefined> = { +const toolDescriptionMap: Record string | undefined | Promise> = { execute_command: (args) => getExecuteCommandDescription(args), read_file: (args) => { // Check if the current model should use the simplified read_file tool @@ -75,9 +76,10 @@ const toolDescriptionMap: Record string | undefined> run_slash_command: () => getRunSlashCommandDescription(), generate_image: (args) => getGenerateImageDescription(args), check_past_chat_memories: (args) => getCheckPastChatMemoriesDescription(args), + use_skill: (args) => getUseSkillDescription(args), } -export function getToolDescriptionsForMode( +export async function getToolDescriptionsForMode( mode: Mode, cwd: string, supportsComputerUse: boolean, @@ -92,7 +94,7 @@ export function getToolDescriptionsForMode( enableMcpServerCreation?: boolean, modelId?: string, clineProviderState?: ClineProviderState, // kilocode_change -): string { +): Promise { const config = getModeConfig(mode, customModes) const args: ToolArgs = { cwd, @@ -176,17 +178,22 @@ export function getToolDescriptionsForMode( } // Map tool descriptions for allowed tools - const descriptions = Array.from(tools).map((toolName) => { - const descriptionFn = toolDescriptionMap[toolName] - if (!descriptionFn) { - return undefined - } + const descriptions = await Promise.all( + Array.from(tools).map(async (toolName) => { + const descriptionFn = toolDescriptionMap[toolName] + if (!descriptionFn) { + return undefined + } + + const result = descriptionFn({ + ...args, + toolOptions: undefined, // No tool options in group-based approach + }) - return descriptionFn({ - ...args, - toolOptions: undefined, // No tool options in group-based approach - }) - }) + // Handle both sync and async description functions + return result instanceof Promise ? await result : result + }), + ) return `# Tools\n\n${descriptions.filter(Boolean).join("\n\n")}` } diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index a1e2d3999c..b6936e5f9d 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -16,6 +16,7 @@ import fileEdit from "./file_edit" import updateTodoList from "./update_todo_list" import codebaseSearch from "./codebase_search" import planFileEdit from "./plan_file_edit" +import useSkill from "./use_skill" export const nativeTools = [ // apply_diff_single_file, @@ -40,5 +41,6 @@ export const nativeTools = [ // searchAndReplace, searchFiles, updateTodoList, + useSkill, // writeToFile, ] satisfies OpenAI.Chat.ChatCompletionTool[] diff --git a/src/core/prompts/tools/native-tools/use_skill.ts b/src/core/prompts/tools/native-tools/use_skill.ts new file mode 100644 index 0000000000..4d88b43b80 --- /dev/null +++ b/src/core/prompts/tools/native-tools/use_skill.ts @@ -0,0 +1,23 @@ +import type OpenAI from "openai" + +export default { + type: "function", + function: { + name: "use_skill", + description: + "Use a specific skill to guide the task execution. This tool allows you to apply predefined skills stored in the workspace's .agent/skills directory. Each skill contains specialized instructions for performing specific tasks or following particular patterns.", + strict: true, + parameters: { + type: "object", + properties: { + skill_name: { + type: "string", + description: + "The name of the skill to use. Must match one of the available skills listed in the tool description.", + }, + }, + required: ["skill_name"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/prompts/tools/use-skill.ts b/src/core/prompts/tools/use-skill.ts new file mode 100644 index 0000000000..89b5c84c44 --- /dev/null +++ b/src/core/prompts/tools/use-skill.ts @@ -0,0 +1,37 @@ +import { discoverSkills } from "../../tools/skills" +import type { ToolArgs } from "./types" + +export async function getUseSkillDescription(args: ToolArgs): Promise { + const skills = await discoverSkills({ workspacePath: args.cwd }) + + if (skills.length === 0) { + return `## use_skill +Description: Use a specific skill to guide the task execution. This tool allows you to apply predefined skills stored in the workspace's .agent/skills directory. +Parameters: +- skill_name: (required) The name of the skill to use. + +No skills are currently available in this workspace. To add skills, create SKILL.md files in .agent/skills// directories.` + } + + const skillList = skills + .map((skill) => { + return ` - ${skill.metadata.name}: ${skill.metadata.description}` + }) + .join("\n") + + const example = skills[0] + ? `Example: Using the "${skills[0].metadata.name}" skill + + +${skills[0].metadata.name} +` + : "" + + return `## use_skill +Description: Use a specific skill to guide the task execution. This tool allows you to apply predefined skills stored in the workspace's .agent/skills directory. Each skill contains specialized instructions for performing specific tasks or following particular patterns. +Parameters: +- skill_name: (required) The name of the skill to use. Available skills: +${skillList} + +${example}` +} diff --git a/src/core/tools/__tests__/useSkillTool.spec.ts b/src/core/tools/__tests__/useSkillTool.spec.ts new file mode 100644 index 0000000000..3b3c67e765 --- /dev/null +++ b/src/core/tools/__tests__/useSkillTool.spec.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { useSkillTool } from "../useSkillTool" +import { Task } from "../../task/Task" +import { formatResponse } from "../../prompts/responses" + +// Mock dependencies +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg) => `${msg}`), + }, +})) + +describe("useSkillTool", () => { + let mockCline: any + let mockHandleError: any + let mockPushToolResult: any + + beforeEach(() => { + mockPushToolResult = vi.fn() + mockHandleError = vi.fn() + + mockCline = { + workspacePath: "/workspace", + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing param error"), + ask: vi.fn().mockResolvedValue({ text: "", images: [] }), + say: vi.fn().mockResolvedValue(undefined), + } as const + }) + + it("should handle partial tool call", async () => { + const block = { + type: "tool_use", + id: "test-id-1", + name: "use_skill", + params: {}, + partial: true, + } as const + + await useSkillTool(mockCline, block, mockHandleError, mockPushToolResult) + + expect(mockCline.ask).toHaveBeenCalledWith("tool", expect.any(String), true) + expect(mockPushToolResult).not.toHaveBeenCalled() + }) + + it("should return error when skill_name is missing", async () => { + const block = { + type: "tool_use", + name: "use_skill", + params: {}, + partial: false, + } as const + + await useSkillTool(mockCline, block, mockHandleError, mockPushToolResult) + + expect(mockCline.consecutiveMistakeCount).toBe(1) + expect(mockCline.recordToolError).toHaveBeenCalledWith("use_skill") + expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith("use_skill", "skill_name") + expect(mockPushToolResult).toHaveBeenCalledWith("Missing param error") + }) + + it("should return error when skill is not found", async () => { + const block = { + type: "tool_use", + name: "use_skill", + params: { skill_name: "non-existent-skill" }, + partial: false, + } as const + + await useSkillTool(mockCline, block, mockHandleError, mockPushToolResult) + + expect(mockCline.say).toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith( + 'Skill "non-existent-skill" not found. Make sure the skill exists in .agent/skills//SKILL.md', + ) + }) + + it("should return skill content when skill is found", async () => { + const block = { + type: "tool_use", + name: "use_skill", + params: { skill_name: "test-skill" }, + partial: false, + } as const + + // Mock the skill discovery to return a skill + vi.doMock( + "../skills", + () => + ({ + getSkillByName: vi.fn().mockResolvedValue({ + metadata: { name: "test-skill", description: "Test skill" }, + content: "# Test Skill\n\nThis is the skill content.", + folderName: "test", + path: "/workspace/.agent/skills/test/SKILL.md", + } as const), + }) as const, + ) + + await useSkillTool(mockCline, block, mockHandleError, mockPushToolResult) + + expect(mockCline.say).toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith( + "You are requested to follow the below instructions\n\n# Test Skill\n\nThis is the skill content.", + ) + }) + + it("should handle errors gracefully", async () => { + const block = { + type: "tool_use", + name: "use_skill", + params: { skill_name: "test-skill" }, + partial: false, + } as const + + // Mock an error in skill discovery + vi.doMock( + "../skills", + () => + ({ + getSkillByName: vi.fn().mockRejectedValue(new Error("Discovery error")), + }) as const, + ) + + await useSkillTool(mockCline, block, mockHandleError, mockPushToolResult) + + expect(mockHandleError).toHaveBeenCalledWith("using skill", expect.any(Error)) + }) +}) diff --git a/src/core/tools/skills/__tests__/index.spec.ts b/src/core/tools/skills/__tests__/index.spec.ts new file mode 100644 index 0000000000..507713aaf0 --- /dev/null +++ b/src/core/tools/skills/__tests__/index.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import { discoverSkills, getSkillByName } from "../index" + +// Mock fs module +vi.mock("fs/promises", () => ({ + access: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), +})) + +describe("discoverSkills", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return empty array when skills directory does not exist", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("Directory not found")) + + const result = await discoverSkills({ workspacePath: "/workspace" }) + + expect(result).toEqual([]) + expect(fs.access).toHaveBeenCalledWith("/workspace/.agent/skills") + }) + + it("should discover skills from valid directory structure", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readdir).mockResolvedValue([ + { name: "skill1", isDirectory: () => true }, + { name: "skill2", isDirectory: () => true }, + ] as any) + vi.mocked(fs.readFile).mockResolvedValueOnce(`--- +name: skill-one +description: First skill +--- + +Content of skill one`).mockResolvedValueOnce(`--- +name: skill-two +description: Second skill +--- + +Content of skill two`) + + const result = await discoverSkills({ workspacePath: "/workspace" }) + + expect(result).toHaveLength(2) + expect(result[0].metadata.name).toBe("skill-one") + expect(result[1].metadata.name).toBe("skill-two") + }) + + it("should skip folders without SKILL.md", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readdir).mockResolvedValue([ + { name: "valid-skill", isDirectory: () => true }, + { name: "invalid-skill", isDirectory: () => true }, + ] as any) + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + `--- +name: valid-skill +description: Valid skill +--- + +Valid content`, + ) + .mockRejectedValueOnce(new Error("File not found")) + + const result = await discoverSkills({ workspacePath: "/workspace" }) + + expect(result).toHaveLength(1) + expect(result[0].metadata.name).toBe("valid-skill") + }) + + it("should skip invalid SKILL.md files", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readdir).mockResolvedValue([ + { name: "valid-skill", isDirectory: () => true }, + { name: "invalid-skill", isDirectory: () => true }, + ] as any) + vi.mocked(fs.readFile).mockResolvedValueOnce(`--- +name: valid-skill +description: Valid skill +--- + +Valid content`).mockResolvedValueOnce(`--- +description: Missing name +--- + +Invalid content`) + + const result = await discoverSkills({ workspacePath: "/workspace" }) + + expect(result).toHaveLength(1) + expect(result[0].metadata.name).toBe("valid-skill") + }) + + it("should handle empty skills directory", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readdir).mockResolvedValue([]) + + const result = await discoverSkills({ workspacePath: "/workspace" }) + + expect(result).toEqual([]) + }) + + it("should handle readdir errors gracefully", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readdir).mockRejectedValue(new Error("Permission denied")) + + const result = await discoverSkills({ workspacePath: "/workspace" }) + + expect(result).toEqual([]) + }) +}) + +describe("getSkillByName", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return skill when found", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readdir).mockResolvedValue([{ name: "skill1", isDirectory: () => true }] as any) + vi.mocked(fs.readFile).mockResolvedValue(`--- +name: target-skill +description: Target skill +--- + +Target content`) + + const result = await getSkillByName("target-skill", { workspacePath: "/workspace" }) + + expect(result).not.toBeNull() + expect(result?.metadata.name).toBe("target-skill") + }) + + it("should return null when skill not found", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readdir).mockResolvedValue([{ name: "skill1", isDirectory: () => true }] as any) + vi.mocked(fs.readFile).mockResolvedValue(`--- +name: other-skill +description: Other skill +--- + +Other content`) + + const result = await getSkillByName("target-skill", { workspacePath: "/workspace" }) + + expect(result).toBeNull() + }) + + it("should return null when skills directory does not exist", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("Directory not found")) + + const result = await getSkillByName("any-skill", { workspacePath: "/workspace" }) + + expect(result).toBeNull() + }) +}) diff --git a/src/core/tools/skills/__tests__/parser.spec.ts b/src/core/tools/skills/__tests__/parser.spec.ts new file mode 100644 index 0000000000..f41a3e54ac --- /dev/null +++ b/src/core/tools/skills/__tests__/parser.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest" +import { parseSkillFile } from "../parser" + +describe("parseSkillFile", () => { + it("should parse a valid SKILL.md file", () => { + const content = `--- +name: test-skill +description: A test skill +license: MIT +metadata: + author: test + version: "1.0.0" +--- + +# Test Skill + +This is the skill content.` + + const result = parseSkillFile(content, "/path/to/SKILL.md", "test-folder") + + expect(result).not.toBeNull() + expect(result?.metadata.name).toBe("test-skill") + expect(result?.metadata.description).toBe("A test skill") + expect(result?.metadata.license).toBe("MIT") + expect(result?.metadata.metadata?.author).toBe("test") + expect(result?.metadata.metadata?.version).toBe("1.0.0") + expect(result?.content).toBe("# Test Skill\n\nThis is the skill content.") + expect(result?.folderName).toBe("test-folder") + expect(result?.path).toBe("/path/to/SKILL.md") + }) + + it("should parse a minimal valid SKILL.md file", () => { + const content = `--- +name: minimal-skill +description: Minimal description +--- + +Content here.` + + const result = parseSkillFile(content, "/path/to/SKILL.md", "minimal-folder") + + expect(result).not.toBeNull() + expect(result?.metadata.name).toBe("minimal-skill") + expect(result?.metadata.description).toBe("Minimal description") + expect(result?.content).toBe("Content here.") + }) + + it("should return null if name is missing", () => { + const content = `--- +description: A skill without name +--- + +Content here.` + + const result = parseSkillFile(content, "/path/to/SKILL.md", "folder") + + expect(result).toBeNull() + }) + + it("should return null if description is missing", () => { + const content = `--- +name: skill-without-description +--- + +Content here.` + + const result = parseSkillFile(content, "/path/to/SKILL.md", "folder") + + expect(result).toBeNull() + }) + + it("should return null if frontmatter is malformed", () => { + const content = `--- +invalid: yaml: content +--- +Content here.` + + const result = parseSkillFile(content, "/path/to/SKILL.md", "folder") + + // gray-matter should still parse this, but let's test the behavior + expect(result).not.toBeNull() + }) + + it("should handle empty content", () => { + const content = `--- +name: empty-skill +description: Empty content +--- + +` + + const result = parseSkillFile(content, "/path/to/SKILL.md", "folder") + + expect(result).not.toBeNull() + expect(result?.content).toBe("") + }) + + it("should handle content with special characters", () => { + const content = `--- +name: special-skill +description: Skill with special chars +--- + +# Title + +\`\`\`typescript +const code = "test"; +\`\`\` + +- List item 1 +- List item 2 +` + + const result = parseSkillFile(content, "/path/to/SKILL.md", "folder") + + expect(result).not.toBeNull() + expect(result?.content).toContain('const code = "test";') + expect(result?.content).toContain("List item 1") + }) +}) diff --git a/src/core/tools/skills/index.ts b/src/core/tools/skills/index.ts new file mode 100644 index 0000000000..e9dbd296f7 --- /dev/null +++ b/src/core/tools/skills/index.ts @@ -0,0 +1,73 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { parseSkillFile } from "./parser" +import type { Skill, SkillDiscoveryOptions } from "./types" + +const SKILLS_DIR = ".agent/skills" +const SKILL_FILE = "SKILL.md" + +export async function discoverSkills(options: SkillDiscoveryOptions): Promise { + const { workspacePath } = options + const skillsDir = path.join(workspacePath, SKILLS_DIR) + + try { + await fs.access(skillsDir) + } catch (error) { + console.warn(`[Skills] error accessing skills directory:`, error) + return [] + } + + const skills: Skill[] = [] + + try { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }) + + // Filter for directories and symlinks that point to directories + const skillFolders: typeof entries = [] + + for (const entry of entries) { + if (entry.isDirectory()) { + skillFolders.push(entry) + } else if (entry.isSymbolicLink()) { + const fullPath = path.join(skillsDir, entry.name) + try { + // Use stat() to follow the symlink and check what it points to + const stats = await fs.stat(fullPath) + if (stats.isDirectory()) { + skillFolders.push(entry) + } + } catch { + // Skip invalid symlinks + } + } + } + + for (const folder of skillFolders) { + const skillPath = path.join(skillsDir, folder.name, SKILL_FILE) + + try { + const content = await fs.readFile(skillPath, "utf-8") + const skill = parseSkillFile(content, skillPath, folder.name) + + if (skill) { + skills.push(skill) + } else { + console.warn(`[Skills] Failed to parse skill from ${folder.name}`) + } + } catch (error) { + console.warn(`[Skills] Could not read SKILL.md from ${folder.name}:`, error) + continue + } + } + } catch (error) { + console.warn("[Skills] Could not read skills directory:", error) + return [] + } + + return skills +} + +export async function getSkillByName(name: string, options: SkillDiscoveryOptions): Promise { + const skills = await discoverSkills(options) + return skills.find((skill) => skill.metadata.name === name) || null +} diff --git a/src/core/tools/skills/parser.ts b/src/core/tools/skills/parser.ts new file mode 100644 index 0000000000..4f6da8fde5 --- /dev/null +++ b/src/core/tools/skills/parser.ts @@ -0,0 +1,35 @@ +import matter from "gray-matter" +import type { Skill, SkillMetadata } from "./types" + +export function parseSkillFile(content: string, path: string, folderName: string): Skill | null { + try { + const parsed = matter(content) + const frontmatter = parsed.data as Partial + + // Validate required fields + if (!frontmatter.name || typeof frontmatter.name !== "string") { + return null + } + + if (!frontmatter.description || typeof frontmatter.description !== "string") { + return null + } + + const metadata: SkillMetadata = { + name: frontmatter.name, + description: frontmatter.description, + license: frontmatter.license, + metadata: frontmatter.metadata, + } + + return { + metadata, + content: parsed.content.trim(), + folderName, + path, + } + } catch (error) { + // If frontmatter parsing fails, return null + return null + } +} diff --git a/src/core/tools/skills/types.ts b/src/core/tools/skills/types.ts new file mode 100644 index 0000000000..b8a5c7d6c4 --- /dev/null +++ b/src/core/tools/skills/types.ts @@ -0,0 +1,21 @@ +export interface SkillMetadata { + name: string + description: string + license?: string + metadata?: { + author?: string + version?: string + [key: string]: any + } +} + +export interface Skill { + metadata: SkillMetadata + content: string + folderName: string + path: string +} + +export interface SkillDiscoveryOptions { + workspacePath: string +} diff --git a/src/core/tools/useSkillTool.ts b/src/core/tools/useSkillTool.ts new file mode 100644 index 0000000000..fecac1b4ca --- /dev/null +++ b/src/core/tools/useSkillTool.ts @@ -0,0 +1,59 @@ +import { Task } from "../task/Task" +import { getSkillByName } from "./skills" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { formatResponse } from "../prompts/responses" +import { ToolUse, HandleError, PushToolResult } from "../../shared/tools" + +export async function useSkillTool( + cline: Task, + block: ToolUse, + handleError: HandleError, + pushToolResult: PushToolResult, +) { + const skillName: string | undefined = block.params.skill_name + const sharedMessageProps: ClineSayTool = { tool: "useSkill", content: skillName } + + try { + if (block.partial) { + const partialMessage = JSON.stringify({ ...sharedMessageProps, content: undefined } satisfies ClineSayTool) + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + return + } else { + if (!skillName) { + cline.consecutiveMistakeCount++ + cline.recordToolError("use_skill") + pushToolResult(await cline.sayAndCreateMissingParamError("use_skill", "skill_name")) + return + } + + cline.consecutiveMistakeCount = 0 + + // Show in UI that we're loading the skill (no approval needed - read-only operation) + const completeMessage = JSON.stringify({ ...sharedMessageProps, content: skillName } satisfies ClineSayTool) + await cline.say("tool" as any, completeMessage) + + // Fetch the skill content + const skill = await getSkillByName(skillName, { workspacePath: cline.workspacePath }) + + if (!skill) { + pushToolResult( + formatResponse.toolError( + `Skill "${skillName}" not found. Make sure the skill exists in .agent/skills//SKILL.md`, + ), + ) + return + } + + // Format the response with the skill content + const formattedResponse = `You are requested to follow the below instructions + +${skill.content}` + + pushToolResult(formattedResponse) + + return + } + } catch (error) { + await handleError("using skill", error) + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6b2d573747..9124c815c2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -446,7 +446,6 @@ async function getGitChanges(cwd: string): Promise<{ files: any[]; gitDiff: stri if (filePath) { // Skip files that match skip patterns if (shouldSkipFile(filePath)) { - console.log(`[Code Review] Skipping file: ${filePath}`) continue } @@ -497,7 +496,6 @@ async function getGitChanges(cwd: string): Promise<{ files: any[]; gitDiff: stri // Skip files that match skip patterns if (shouldSkipFile(filePath)) { - console.log(`[Code Review] Skipping untracked file: ${filePath}`) continue } @@ -546,7 +544,6 @@ async function getGitChanges(cwd: string): Promise<{ files: any[]; gitDiff: stri // Check if this file should be skipped includeCurrentFile = !shouldSkipFile(currentFile) if (!includeCurrentFile) { - console.log(`[Code Review] Filtering diff for: ${currentFile}`) } } diff --git a/src/package.json b/src/package.json index 16b1cc8edf..fce02c300f 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "matterai", - "version": "5.2.9", + "version": "5.3.0", "icon": "assets/icons/matterai-ic.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index 8cb25ee500..98960f4cbf 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -1,20 +1,19 @@ +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import fs from "fs/promises" +import ignore from "ignore" +import path from "path" import * as vscode from "vscode" import { ContextProxy } from "../../core/config/ContextProxy" +import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" +import { CacheManager } from "./cache-manager" +import { CodeIndexConfigManager } from "./config-manager" import { VectorStoreSearchResult } from "./interfaces" import { IndexingState } from "./interfaces/manager" -import { CodeIndexConfigManager } from "./config-manager" -import { CodeIndexStateManager } from "./state-manager" -import { CodeIndexServiceFactory } from "./service-factory" -import { CodeIndexSearchService } from "./search-service" import { CodeIndexOrchestrator } from "./orchestrator" -import { CacheManager } from "./cache-manager" -import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" -import fs from "fs/promises" -import ignore from "ignore" -import path from "path" -import { t } from "../../i18n" -import { TelemetryService } from "@roo-code/telemetry" -import { TelemetryEventName } from "@roo-code/types" +import { CodeIndexSearchService } from "./search-service" +import { CodeIndexServiceFactory } from "./service-factory" +import { CodeIndexStateManager } from "./state-manager" export class CodeIndexManager { // --- Singleton Implementation --- @@ -71,6 +70,7 @@ export class CodeIndexManager { this.workspacePath = workspacePath this.context = context this._stateManager = new CodeIndexStateManager() + this._stateManager.setContext(context) } // --- Public API --- @@ -124,6 +124,9 @@ export class CodeIndexManager { */ public async initialize(contextProxy: ContextProxy): Promise<{ requiresRestart: boolean }> { try { + // 0. Load persisted state before anything else + await this._stateManager.loadPersistedState() + // 1. ConfigManager Initialization and Configuration Loading if (!this._configManager) { this._configManager = new CodeIndexConfigManager(contextProxy) @@ -162,12 +165,19 @@ export class CodeIndexManager { } // 6. Handle Indexing Start/Restart + // Only start indexing if: restart is required OR services need recreation AND state is not already Indexed/Indexing const shouldStartOrRestartIndexing = requiresRestart || - (needsServiceRecreation && (!this._orchestrator || this._orchestrator.state !== "Indexing")) + (needsServiceRecreation && + (!this._orchestrator || + this._orchestrator.state === "Standby" || + this._orchestrator.state === "Error")) if (shouldStartOrRestartIndexing) { this._orchestrator?.startIndexing() // This method is async, but we don't await it here + } else if (this._orchestrator && this._orchestrator.state === "Indexed" && needsServiceRecreation) { + // State is already Indexed and services were recreated - just start the watcher + this._orchestrator.startWatcherOnly() // This method is async, but we don't await it here } return { requiresRestart } @@ -314,6 +324,10 @@ export class CodeIndexManager { * Used by both initialize() and handleSettingsChange(). */ private async _recreateServices(): Promise { + // Preserve the current state before recreating services + const currentState = this._stateManager.state + const currentMessage = this._stateManager.getCurrentStatus().message + // Stop watcher if it exists if (this._orchestrator) { this.stopWatcher() @@ -399,8 +413,13 @@ export class CodeIndexManager { vectorStore, ) - // Clear any error state after successful recreation - this._stateManager.setSystemState("Standby", "") + // Restore the previous state if it was "Indexed", otherwise clear to "Standby" + if (currentState === "Indexed") { + this._stateManager.setSystemState("Indexed", currentMessage || "Index up-to-date.") + } else { + // Clear any error state after successful recreation + this._stateManager.setSystemState("Standby", "") + } } /** diff --git a/src/services/code-index/orchestrator.ts b/src/services/code-index/orchestrator.ts index 2de236ee72..0e8c8cc06c 100644 --- a/src/services/code-index/orchestrator.ts +++ b/src/services/code-index/orchestrator.ts @@ -88,6 +88,74 @@ export class CodeIndexOrchestrator { } } + /** + * Starts only the file watcher without re-scanning the workspace. + * This is used when the extension restarts and the index is already up-to-date. + */ + public async startWatcherOnly(): Promise { + if (!this.configManager.isFeatureConfigured) { + throw new Error("Cannot start watcher: Service not configured.") + } + + // Check if watcher is already running + if (this._fileWatcherSubscriptions.length > 0) { + console.log("[CodeIndexOrchestrator] File watcher already running, skipping.") + return + } + + try { + await this.fileWatcher.initialize() + + this._fileWatcherSubscriptions = [ + this.fileWatcher.onDidStartBatchProcessing((filePaths: string[]) => {}), + this.fileWatcher.onBatchProgressUpdate(({ processedInBatch, totalInBatch, currentFile }) => { + if (totalInBatch > 0 && this.stateManager.state !== "Indexing") { + this.stateManager.setSystemState("Indexing", "Processing file changes...") + } + this.stateManager.reportFileQueueProgress( + processedInBatch, + totalInBatch, + currentFile ? path.basename(currentFile) : undefined, + ) + if (processedInBatch === totalInBatch) { + // Covers (N/N) and (0/0) + if (totalInBatch > 0) { + // Batch with items completed + this.stateManager.setSystemState("Indexed", "File changes processed. Index up-to-date.") + } else { + if (this.stateManager.state === "Indexing") { + // Only transition if it was "Indexing" + this.stateManager.setSystemState("Indexed", "Index up-to-date. File queue empty.") + } + } + } + }), + this.fileWatcher.onDidFinishBatchProcessing((summary: BatchProcessingSummary) => { + if (summary.batchError) { + console.error(`[CodeIndexOrchestrator] Batch processing failed:`, summary.batchError) + } else { + const successCount = summary.processedFiles.filter( + (f: { status: string }) => f.status === "success", + ).length + const errorCount = summary.processedFiles.filter( + (f: { status: string }) => f.status === "error" || f.status === "local_error", + ).length + } + }), + ] + + console.log("[CodeIndexOrchestrator] File watcher started successfully (watcher-only mode).") + } catch (error) { + console.error("[CodeIndexOrchestrator] Failed to start file watcher:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "startWatcherOnly", + }) + throw error + } + } + /** * Updates the status of a file in the state manager. */ diff --git a/src/services/code-index/state-manager.ts b/src/services/code-index/state-manager.ts index 90257fdfb1..ffed971bdf 100644 --- a/src/services/code-index/state-manager.ts +++ b/src/services/code-index/state-manager.ts @@ -2,6 +2,8 @@ import * as vscode from "vscode" export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" +const STATE_STORAGE_KEY = "codeIndexState" + export class CodeIndexStateManager { private _systemStatus: IndexingState = "Standby" private _statusMessage: string = "" @@ -9,6 +11,7 @@ export class CodeIndexStateManager { private _totalItems: number = 0 private _currentItemUnit: string = "blocks" private _progressEmitter = new vscode.EventEmitter>() + private _context: vscode.ExtensionContext | undefined // --- Public API --- @@ -18,6 +21,13 @@ export class CodeIndexStateManager { return this._systemStatus } + /** + * Sets the extension context for state persistence + */ + public setContext(context: vscode.ExtensionContext): void { + this._context = context + } + public getCurrentStatus() { return { systemStatus: this._systemStatus, @@ -52,6 +62,53 @@ export class CodeIndexStateManager { } this._progressEmitter.fire(this.getCurrentStatus()) + this._persistState() + } + } + + /** + * Loads the persisted state from extension context + */ + public async loadPersistedState(): Promise { + if (!this._context) { + return + } + + try { + const persistedState = this._context.globalState.get(STATE_STORAGE_KEY) + if (persistedState && (persistedState === "Indexed" || persistedState === "Standby")) { + // Only restore "Indexed" or "Standby" states - never restore "Indexing" or "Error" on restart + this._systemStatus = persistedState + if (persistedState === "Indexed") { + this._statusMessage = "Index up-to-date." + } else { + this._statusMessage = "Ready." + } + console.log(`[CodeIndexStateManager] Restored state: ${persistedState}`) + } + } catch (error) { + console.error("[CodeIndexStateManager] Failed to load persisted state:", error) + } + } + + /** + * Persists the current state to extension context + */ + private _persistState(): void { + if (!this._context) { + return + } + + try { + // Only persist "Indexed" state - other states should reset on restart + if (this._systemStatus === "Indexed") { + this._context.globalState.update(STATE_STORAGE_KEY, this._systemStatus) + } else { + // Clear persisted state if not Indexed + this._context.globalState.update(STATE_STORAGE_KEY, undefined) + } + } catch (error) { + console.error("[CodeIndexStateManager] Failed to persist state:", error) } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 553ace2634..fbf1e7f297 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -491,6 +491,7 @@ export interface ClineSayTool { | "planFileEdit" // kilocode_change: Plan mode file editing | "codeReview" // kilocode_change: AI Code Review | "checkPastChatMemories" // Chat memories feature + | "useSkill" path?: string diff?: string content?: string diff --git a/src/shared/tools.ts b/src/shared/tools.ts index b718e9bb16..404094ce0b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -81,6 +81,7 @@ export const toolParamNames = [ "prompt", "image", "workspace", + "skill_name", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -216,6 +217,11 @@ export interface GenerateImageToolUse extends ToolUse { params: Partial, "prompt" | "path" | "image">> } +export interface UseSkillToolUse extends ToolUse { + name: "use_skill" + params: Required, "skill_name">> +} + // Define tool group configuration export type ToolGroupConfig = { tools: readonly string[] @@ -251,6 +257,7 @@ export const TOOL_DISPLAY_NAMES: Record = { generate_image: "generate images", plan_file_edit: "edit plan files", // kilocode_change: Plan mode file editing check_past_chat_memories: "check past chat memories", + use_skill: "use skill", } as const // Define available tool groups. @@ -264,6 +271,7 @@ export const TOOL_GROUPS: Record = { "list_code_definition_names", "codebase_search", "check_past_chat_memories", + "use_skill", ], }, edit: { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index a2579f826b..1babf2a09a 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -79,7 +79,7 @@ interface ChatRowProps { } // eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface ChatRowContentProps extends Omit { } +interface ChatRowContentProps extends Omit {} const headerStyle: React.CSSProperties = { display: "flex", @@ -165,38 +165,41 @@ export const ChatRowContent = ({ const [editMode, setEditMode] = useState(mode || "code") const [editImages, setEditImages] = useState([]) - const streamingWords = useMemo(() => [ - "Synapsing", - "Materializing", - "Architecting", - "Crystallizing", - "Orchestrating", - "Synthesizing", - "Constructing", - "Pulsing", - "Solidifying", - "Evolving", - "Synapsing", - "Materializing", - "Architecting", - "Crystallizing", - "Orchestrating", - "Synthesizing", - "Constructing", - "Pulsing", - "Solidifying", - "Evolving", - "Manifesting", - "Firing", - "Assembling", - "Transmitting", - "Formulating", - "Integrating", - "Calibrating", - "Connecting", - "Executing", - "Resonating", - ], []); + const streamingWords = useMemo( + () => [ + "Synapsing", + "Materializing", + "Architecting", + "Crystallizing", + "Orchestrating", + "Synthesizing", + "Constructing", + "Pulsing", + "Solidifying", + "Evolving", + "Synapsing", + "Materializing", + "Architecting", + "Crystallizing", + "Orchestrating", + "Synthesizing", + "Constructing", + "Pulsing", + "Solidifying", + "Evolving", + "Manifesting", + "Firing", + "Assembling", + "Transmitting", + "Formulating", + "Integrating", + "Calibrating", + "Connecting", + "Executing", + "Resonating", + ], + [], + ) const [currentWordIndex, setCurrentWordIndex] = useState(() => Math.floor(Math.random() * streamingWords.length)) useEffect(() => { @@ -390,11 +393,11 @@ export const ChatRowContent = ({ {t("chat:apiRequest.title")} ) : // kilocode_change end - apiRequestFailedMessage ? ( - {t("chat:apiRequest.failed")} - ) : ( - {streamingWords[currentWordIndex]}... - ), + apiRequestFailedMessage ? ( + {t("chat:apiRequest.failed")} + ) : ( + {streamingWords[currentWordIndex]}... + ), ] case "followup": return [ @@ -509,7 +512,7 @@ export const ChatRowContent = ({ style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }} /> ) : // toolIcon("edit") - null} + null} {tool.isProtected ? t("chat:fileOperations.wantsToEditProtected") @@ -635,8 +638,8 @@ export const ChatRowContent = ({ : tool.lineNumber === 0 ? t("chat:fileOperations.wantsToInsertAtEnd") : t("chat:fileOperations.wantsToInsertWithLineNumber", { - lineNumber: tool.lineNumber, - })} + lineNumber: tool.lineNumber, + })}
@@ -795,8 +798,8 @@ export const ChatRowContent = ({ ? t("chat:fileOperations.wantsToReadOutsideWorkspace") : tool.additionalFileCount && tool.additionalFileCount > 0 ? t("chat:fileOperations.wantsToReadAndXMore", { - count: tool.additionalFileCount, - }) + count: tool.additionalFileCount, + }) : t("chat:fileOperations.wantsToRead") : t("chat:fileOperations.didRead")} @@ -1186,6 +1189,23 @@ export const ChatRowContent = ({ )} ) + case "useSkill": + return ( +
+
+ {message.partial ? "Using skill..." : "Used skill"} +
+
+ + + + {tool.content || "unknown"} + + + +
+
+ ) default: return null } @@ -1259,13 +1279,14 @@ export const ChatRowContent = ({ return ( <>
{(((cost === null || cost === undefined) && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && ( - -
-
- {t("chat:powershell.issues")}{" "} - - troubleshooting guide - - . - - ) : undefined - } - /> - )} + +
+
+ {t("chat:powershell.issues")}{" "} + + troubleshooting guide + + . + + ) : undefined + } + /> + )} ) case "api_req_finished": @@ -1604,7 +1625,7 @@ export const ChatRowContent = ({ ) // kilocode_change end case "user_edit_todos": - return { }} /> + return {}} /> case "tool" as any: // Handle say tool messages const sayTool = safeJsonParse(message.text)