Merge pull request #3068 from code-yeongyu/refactor/deslop-tools

refactor(tools): split tool constants and skill creators
This commit is contained in:
YeonGyu-Kim
2026-04-03 21:37:27 +09:00
committed by GitHub
24 changed files with 748 additions and 601 deletions
@@ -0,0 +1,54 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require moderate effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires more than trivial effort but is NOT system-wide
3. Scope is contained within a few files/modules
If task fits ANY other category, DO NOT select unspecified-low.
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
</Selection_Gate>
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
**PROVIDE CLEAR STRUCTURE:**
1. MUST DO: Enumerate required actions explicitly
2. MUST NOT DO: State forbidden actions to prevent scope creep
3. EXPECTED OUTPUT: Define concrete success criteria
</Caller_Warning>`
const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require substantial effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires substantial effort across multiple systems/modules
3. Changes have broad impact or require careful coordination
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
If task fits ANY other category, DO NOT select unspecified-high.
If task is unclassifiable but moderate-effort, use unspecified-low instead.
</Selection_Gate>
</Category_Context>`
export const ANTHROPIC_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "unspecified-low",
config: { model: "anthropic/claude-sonnet-4-6" },
description: "Tasks that don't fit other categories, low effort required",
promptAppend: UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
},
{
name: "unspecified-high",
config: { model: "anthropic/claude-opus-4-6", variant: "max" },
description: "Tasks that don't fit other categories, high effort required",
promptAppend: UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
},
]
@@ -0,0 +1,33 @@
import type { CategoryConfig } from "../../config/schema"
import { ANTHROPIC_CATEGORIES } from "./anthropic-categories"
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
import { GOOGLE_CATEGORIES } from "./google-categories"
import { KIMI_CATEGORIES } from "./kimi-categories"
import { OPENAI_CATEGORIES } from "./openai-categories"
const BUILTIN_CATEGORIES: BuiltinCategoryDefinition[] = [
...GOOGLE_CATEGORIES,
...OPENAI_CATEGORIES,
...ANTHROPIC_CATEGORIES,
...KIMI_CATEGORIES,
]
function buildCategoryRecord<TValue>(
selector: (definition: BuiltinCategoryDefinition) => TValue
): Record<string, TValue> {
return Object.fromEntries(
BUILTIN_CATEGORIES.map((definition) => [definition.name, selector(definition)])
)
}
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = buildCategoryRecord(
(definition) => definition.config
)
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = buildCategoryRecord(
(definition) => definition.promptAppend
)
export const CATEGORY_DESCRIPTIONS: Record<string, string> = buildCategoryRecord(
(definition) => definition.description
)
@@ -0,0 +1,8 @@
import type { CategoryConfig } from "../../config/schema"
export type BuiltinCategoryDefinition = {
name: string
config: CategoryConfig
description: string
promptAppend: string
}
+5 -300
View File
@@ -1,308 +1,13 @@
import type { CategoryConfig } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
import { truncateDescription } from "../../shared/truncate-description"
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks.
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code — you MUST:**
1. **SEARCH for the design system.** Use Grep, Glob, Read — actually LOOK:
- Design tokens: colors, spacing, typography, shadows, border-radii
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
- Shared/base components: Button, Card, Input, Layout primitives
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
2. **READ at minimum 5-10 existing UI components.** Understand:
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
- Color usage (semantic tokens? Direct hex? Theme references?)
- Typography scale (heading levels, body, caption — how many? What font stack?)
- Component composition patterns (slots? children? compound components?)
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
1. **STOP. Do NOT build the requested UI yet.**
2. **Extract what exists** — even inconsistent patterns have salvageable decisions.
3. **Create a minimal design system FIRST:**
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
- Spacing scale: consistent increments (4px or 8px base)
- Border radii, shadows, transitions — systematic, not random
- Component primitives: the reusable building blocks
4. **Commit/save the design system, THEN proceed to Phase 3.**
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
**NOW and ONLY NOW** — implement the requested visual work:
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|---------|---------|--------------------------|
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
| Border radius | System token | Random \`border-radius: 6px\` |
**IF the design requires something OUTSIDE the current system:**
- **Extend the system FIRST** — add the new token/primitive
- **THEN use the new token** in your component
- **NEVER one-off override.** That is how design systems die.
### PHASE 4: VERIFY BEFORE CLAIMING DONE
BEFORE reporting visual work as complete, answer these:
- [ ] Does EVERY color reference a design token or CSS variable?
- [ ] Does EVERY spacing use the system scale?
- [ ] Does EVERY component follow the existing composition pattern?
- [ ] Would a designer see CONSISTENCY across old and new components?
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
**If ANY answer is NO — FIX IT. You are NOT done.**
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
<DESIGN_QUALITY>
Design-first mindset (AFTER design system is established):
- Bold aesthetic choices over safe defaults
- Unexpected layouts, asymmetry, grid-breaking elements
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
- Cohesive color palettes with sharp accents
- High-impact animations with staggered reveals
- Atmosphere: gradient meshes, noise textures, layered transparencies
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
</DESIGN_QUALITY>
</Category_Context>`
export const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
2. Your code MUST match the project's existing conventions - blend in seamlessly
3. Write READABLE code that humans can easily understand - no clever tricks
4. If unsure about style, explore more files until you find the pattern
Strategic advisor mindset:
- Bias toward simplicity: least complex solution that fulfills requirements
- Leverage existing code/patterns over new components
- Prioritize developer experience and maintainability
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
- Signal when advanced approach warranted
Response format:
- Bottom line (2-3 sentences)
- Action plan (numbered steps)
- Risks and mitigations (if relevant)
</Category_Context>`
export const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
Artistic genius mindset:
- Push far beyond conventional boundaries
- Explore radical, unconventional directions
- Surprise and delight: unexpected twists, novel combinations
- Rich detail and vivid expression
- Break patterns deliberately when it serves the creative vision
Approach:
- Generate diverse, bold options first
- Embrace ambiguity and wild experimentation
- Balance novelty with coherence
- This is for tasks requiring exceptional creativity
</Category_Context>`
export const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on SMALL / QUICK tasks.
Efficient execution mindset:
- Fast, focused, minimal overhead
- Get to the point immediately
- No over-engineering
- Simple solutions for simple problems
Approach:
- Minimal viable implementation
- Skip unnecessary abstractions
- Direct and concise
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
The model executing this task is optimized for speed over depth. Your prompt MUST be:
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
1. MUST DO: List every required action as atomic, numbered steps
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
**WHY THIS MATTERS:**
- Smaller models benefit from explicit guardrails
- Vague instructions may lead to unpredictable results
- Implicit expectations may be missed
**PROMPT STRUCTURE (MANDATORY):**
\`\`\`
TASK: [One-sentence goal]
MUST DO:
1. [Specific action with exact details]
2. [Another specific action]
...
MUST NOT DO:
- [Forbidden action + why]
- [Another forbidden action]
...
EXPECTED OUTPUT:
- [Exact deliverable description]
- [Success criteria / verification method]
\`\`\`
If your prompt lacks this structure, REWRITE IT before delegating.
</Caller_Warning>`
export const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require moderate effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires more than trivial effort but is NOT system-wide
3. Scope is contained within a few files/modules
If task fits ANY other category, DO NOT select unspecified-low.
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
</Selection_Gate>
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
**PROVIDE CLEAR STRUCTURE:**
1. MUST DO: Enumerate required actions explicitly
2. MUST NOT DO: State forbidden actions to prevent scope creep
3. EXPECTED OUTPUT: Define concrete success criteria
</Caller_Warning>`
export const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require substantial effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires substantial effort across multiple systems/modules
3. Changes have broad impact or require careful coordination
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
If task fits ANY other category, DO NOT select unspecified-high.
If task is unclassifiable but moderate-effort, use unspecified-low instead.
</Selection_Gate>
</Category_Context>`
export const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on WRITING / PROSE tasks.
Wordsmith mindset:
- Clear, flowing prose
- Appropriate tone and voice
- Engaging and readable
- Proper structure and organization
Approach:
- Understand the audience
- Draft with care
- Polish for clarity and impact
- Documentation, READMEs, articles, technical writing
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
- NEVER use em dashes (—) or en dashes (). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
- Vary sentence length. Don't make every sentence the same length.
- NEVER start consecutive sentences with the same word.
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
- Write like a human, not a corporate template.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
You are NOT an interactive assistant. You are an autonomous problem-solver.
BEFORE making ANY changes:
1. Silently explore the codebase extensively (5-15 minutes of reading is normal)
2. Read related files, trace dependencies, understand the full context
3. Build a complete mental model of the problem space
4. Do not ask clarifying questions - the goal is already defined
You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps, not as separate independent tasks. Figure out HOW to achieve it yourself. Thorough research before any action.
Sub-steps of ONE goal = execute all steps as phases of one atomic task.
Genuinely independent tasks = flag and refuse, require separate delegations.
Approach: explore extensively, understand deeply, then act decisively. Prefer comprehensive solutions over quick patches. If the goal is unclear, make reasonable assumptions and proceed.
Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
</Category_Context>`
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
deep: { model: "openai/gpt-5.4", variant: "medium" },
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
quick: { model: "openai/gpt-5.4-mini" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
writing: { model: "kimi-for-coding/k2p5" },
}
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = {
"visual-engineering": VISUAL_CATEGORY_PROMPT_APPEND,
ultrabrain: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
deep: DEEP_CATEGORY_PROMPT_APPEND,
artistry: ARTISTRY_CATEGORY_PROMPT_APPEND,
quick: QUICK_CATEGORY_PROMPT_APPEND,
"unspecified-low": UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
"unspecified-high": UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
writing: WRITING_CATEGORY_PROMPT_APPEND,
}
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
"visual-engineering": "Frontend, UI/UX, design, styling, animation",
ultrabrain: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
deep: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
artistry: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
quick: "Trivial tasks - single file changes, typo fixes, simple modifications",
"unspecified-low": "Tasks that don't fit other categories, low effort required",
"unspecified-high": "Tasks that don't fit other categories, high effort required",
writing: "Documentation, prose, technical writing",
}
export {
CATEGORY_DESCRIPTIONS,
CATEGORY_PROMPT_APPENDS,
DEFAULT_CATEGORIES,
} from "./builtin-categories"
/**
* System prompt prepended to plan agent invocations.
@@ -0,0 +1,122 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks.
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code — you MUST:**
1. **SEARCH for the design system.** Use Grep, Glob, Read — actually LOOK:
- Design tokens: colors, spacing, typography, shadows, border-radii
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
- Shared/base components: Button, Card, Input, Layout primitives
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
2. **READ at minimum 5-10 existing UI components.** Understand:
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
- Color usage (semantic tokens? Direct hex? Theme references?)
- Typography scale (heading levels, body, caption — how many? What font stack?)
- Component composition patterns (slots? children? compound components?)
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
1. **STOP. Do NOT build the requested UI yet.**
2. **Extract what exists** — even inconsistent patterns have salvageable decisions.
3. **Create a minimal design system FIRST:**
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
- Spacing scale: consistent increments (4px or 8px base)
- Border radii, shadows, transitions — systematic, not random
- Component primitives: the reusable building blocks
4. **Commit/save the design system, THEN proceed to Phase 3.**
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
**NOW and ONLY NOW** — implement the requested visual work:
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|---------|---------|--------------------------|
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
| Border radius | System token | Random \`border-radius: 6px\` |
**IF the design requires something OUTSIDE the current system:**
- **Extend the system FIRST** — add the new token/primitive
- **THEN use the new token** in your component
- **NEVER one-off override.** That is how design systems die.
### PHASE 4: VERIFY BEFORE CLAIMING DONE
BEFORE reporting visual work as complete, answer these:
- [ ] Does EVERY color reference a design token or CSS variable?
- [ ] Does EVERY spacing use the system scale?
- [ ] Does EVERY component follow the existing composition pattern?
- [ ] Would a designer see CONSISTENCY across old and new components?
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
**If ANY answer is NO — FIX IT. You are NOT done.**
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
<DESIGN_QUALITY>
Design-first mindset (AFTER design system is established):
- Bold aesthetic choices over safe defaults
- Unexpected layouts, asymmetry, grid-breaking elements
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
- Cohesive color palettes with sharp accents
- High-impact animations with staggered reveals
- Atmosphere: gradient meshes, noise textures, layered transparencies
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
</DESIGN_QUALITY>
</Category_Context>`
const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
Artistic genius mindset:
- Push far beyond conventional boundaries
- Explore radical, unconventional directions
- Surprise and delight: unexpected twists, novel combinations
- Rich detail and vivid expression
- Break patterns deliberately when it serves the creative vision
Approach:
- Generate diverse, bold options first
- Embrace ambiguity and wild experimentation
- Balance novelty with coherence
- This is for tasks requiring exceptional creativity
</Category_Context>`
export const GOOGLE_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "visual-engineering",
config: { model: "google/gemini-3.1-pro", variant: "high" },
description: "Frontend, UI/UX, design, styling, animation",
promptAppend: VISUAL_CATEGORY_PROMPT_APPEND,
},
{
name: "artistry",
config: { model: "google/gemini-3.1-pro", variant: "high" },
description: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
promptAppend: ARTISTRY_CATEGORY_PROMPT_APPEND,
},
]
@@ -0,0 +1,36 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on WRITING / PROSE tasks.
Wordsmith mindset:
- Clear, flowing prose
- Appropriate tone and voice
- Engaging and readable
- Proper structure and organization
Approach:
- Understand the audience
- Draft with care
- Polish for clarity and impact
- Documentation, READMEs, articles, technical writing
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
- NEVER use em dashes (—) or en dashes (). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
- Vary sentence length. Don't make every sentence the same length.
- NEVER start consecutive sentences with the same word.
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
- Write like a human, not a corporate template.
</Category_Context>`
export const KIMI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "writing",
config: { model: "kimi-for-coding/k2p5" },
description: "Documentation, prose, technical writing",
promptAppend: WRITING_CATEGORY_PROMPT_APPEND,
},
]
@@ -0,0 +1,116 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
2. Your code MUST match the project's existing conventions - blend in seamlessly
3. Write READABLE code that humans can easily understand - no clever tricks
4. If unsure about style, explore more files until you find the pattern
Strategic advisor mindset:
- Bias toward simplicity: least complex solution that fulfills requirements
- Leverage existing code/patterns over new components
- Prioritize developer experience and maintainability
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
- Signal when advanced approach warranted
Response format:
- Bottom line (2-3 sentences)
- Action plan (numbered steps)
- Risks and mitigations (if relevant)
</Category_Context>`
const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
You are NOT an interactive assistant. You are an autonomous problem-solver.
BEFORE making ANY changes:
1. Silently explore the codebase extensively (5-15 minutes of reading is normal)
2. Read related files, trace dependencies, understand the full context
3. Build a complete mental model of the problem space
4. Do not ask clarifying questions - the goal is already defined
You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps, not as separate independent tasks. Figure out HOW to achieve it yourself. Thorough research before any action.
Sub-steps of ONE goal = execute all steps as phases of one atomic task.
Genuinely independent tasks = flag and refuse, require separate delegations.
Approach: explore extensively, understand deeply, then act decisively. Prefer comprehensive solutions over quick patches. If the goal is unclear, make reasonable assumptions and proceed.
Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
</Category_Context>`
const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on SMALL / QUICK tasks.
Efficient execution mindset:
- Fast, focused, minimal overhead
- Get to the point immediately
- No over-engineering
- Simple solutions for simple problems
Approach:
- Minimal viable implementation
- Skip unnecessary abstractions
- Direct and concise
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
The model executing this task is optimized for speed over depth. Your prompt MUST be:
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
1. MUST DO: List every required action as atomic, numbered steps
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
**WHY THIS MATTERS:**
- Smaller models benefit from explicit guardrails
- Vague instructions may lead to unpredictable results
- Implicit expectations may be missed
**PROMPT STRUCTURE (MANDATORY):**
\`\`\`
TASK: [One-sentence goal]
MUST DO:
1. [Specific action with exact details]
2. [Another specific action]
...
MUST NOT DO:
- [Forbidden action + why]
- [Another forbidden action]
...
EXPECTED OUTPUT:
- [Exact deliverable description]
- [Success criteria / verification method]
\`\`\`
If your prompt lacks this structure, REWRITE IT before delegating.
</Caller_Warning>`
export const OPENAI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "ultrabrain",
config: { model: "openai/gpt-5.4", variant: "xhigh" },
description: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
promptAppend: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
},
{
name: "deep",
config: { model: "openai/gpt-5.4", variant: "medium" },
description: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
promptAppend: DEEP_CATEGORY_PROMPT_APPEND,
},
{
name: "quick",
config: { model: "openai/gpt-5.4-mini" },
description: "Trivial tasks - single file changes, typo fixes, simple modifications",
promptAppend: QUICK_CATEGORY_PROMPT_APPEND,
},
]
@@ -141,8 +141,6 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
}
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
// Cold cache: resolution was skipped but user explicitly configured a model.
// Honor the user override directly — don't fall through to hardcoded fallback chain.
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
if (normalized) {
const agentCategoryVariant = agentOverride?.category
@@ -164,8 +162,6 @@ Create the work plan directly - that's your job as the planning agent.`,
normalizedAgentFallbackModels,
defaultProviderID,
)
// Don't assign hardcoded fallback chain when resolution was skipped (cold cache)
// — the chain may contain model IDs that don't exist in the provider yet.
fallbackChain = configuredFallbackChain ?? (resolutionSkipped ? undefined : agentRequirement?.fallbackChain)
// Only promote fallback-only settings when resolution actually selected a fallback model.
@@ -227,10 +227,10 @@ describe("hashline edit operations", () => {
})
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
//#given reproduces the 애국가 bug where blank+indented lines collapse
//#given, reproduces the 애국가 bug where blank+indented lines collapse
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
//#when replace the range with indented version (blank lines preserved)
//#when, replace the range with indented version (blank lines preserved)
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
@@ -238,7 +238,7 @@ describe("hashline edit operations", () => {
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
)
//#then all 7 lines preserved with indentation, not collapsed to 3
//#then, all 7 lines preserved with indentation, not collapsed to 3
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
})
@@ -350,10 +350,10 @@ describe("runFormattersForFile", () => {
},
})
//#when run for a .go file, but only .ts formatters registered
//#when, run for a .go file, but only .ts formatters registered
await runFormattersForFile(client, "/project", "/src/main.go")
//#then no error thrown
//#then, no error thrown
})
it("runs formatter for matching extension", async () => {
@@ -367,10 +367,10 @@ describe("runFormattersForFile", () => {
},
})
//#when echo is a safe no-op command
//#when, echo is a safe no-op command
await runFormattersForFile(client, "/tmp", "/tmp/test.ts")
//#then should complete without error
//#then, should complete without error
expect(client.config.get).toHaveBeenCalledTimes(1)
})
})
+6 -6
View File
@@ -23,10 +23,10 @@ describe("parseLineRef", () => {
})
it("gives specific hint when literal text is used instead of line number", () => {
//#given model sends "LINE#HK" instead of "1#HK"
//#given, model sends "LINE#HK" instead of "1#HK"
const ref = "LINE#HK"
//#when / #then error should mention that LINE is not a valid number
//#when / #then, error should mention that LINE is not a valid number
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
@@ -39,10 +39,10 @@ describe("parseLineRef", () => {
})
it("extracts valid line number from mixed prefix like LINE42 without throwing", () => {
//#given normalizeLineRef extracts 42#VK from LINE42#VK
//#given, normalizeLineRef extracts 42#VK from LINE42#VK
const ref = "LINE42#VK"
//#when / #then should parse successfully as line 42
//#when / #then, should parse successfully as line 42
const result = parseLineRef(ref)
expect(result.line).toBe(42)
expect(result.hash).toBe("VK")
@@ -144,11 +144,11 @@ describe("validateLineRef", () => {
})
it("suggests correct line number when hash matches a file line", () => {
//#given model sends LINE#XX where XX is the actual hash for line 1
//#given, model sends LINE#XX where XX is the actual hash for line 1
const lines = ["function hello() {", " return 42", "}"]
const hash = computeLineHash(1, lines[0])
//#when / #then error should suggest the correct reference
//#when / #then, error should suggest the correct reference
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
})
})
-1
View File
@@ -48,7 +48,6 @@ export function parseLineRef(ref: string): LineRef {
hash: match[2],
}
}
// normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
+1 -2
View File
@@ -20,8 +20,7 @@ describe("isServerInstalled", () => {
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true })
} catch (e) {
// cleanup failed — ignored
} catch {
}
if (process.platform === "win32") {
+20 -8
View File
@@ -1,3 +1,5 @@
import { log } from "../../shared/logger"
type ManagedClientForCleanup = {
client: {
stop: () => Promise<void>;
@@ -22,23 +24,32 @@ export type LspProcessCleanupHandle = {
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
const handlers: RegisteredHandler[] = [];
// Synchronous cleanup for 'exit' event (cannot await)
const logCleanupError = (phase: string, error: unknown): void => {
log(`[lsp-manager-process-cleanup] ${phase}`, {
error: error instanceof Error ? error.message : String(error),
});
};
const syncCleanup = () => {
for (const [, managed] of options.getClients()) {
try {
// Fire-and-forget during sync exit - process is terminating
void managed.client.stop().catch(() => {});
} catch {}
void managed.client.stop().catch((error) => {
logCleanupError("stop failed during exit cleanup", error);
});
} catch (error) {
logCleanupError("failed to schedule exit cleanup", error);
}
}
options.clearClients();
options.clearCleanupInterval();
};
// Async cleanup for signal handlers - properly await all stops
const asyncCleanup = async () => {
const stopPromises: Promise<void>[] = [];
for (const [, managed] of options.getClients()) {
stopPromises.push(managed.client.stop().catch(() => {}));
stopPromises.push(managed.client.stop().catch((error) => {
logCleanupError("stop failed during signal cleanup", error);
}));
}
await Promise.allSettled(stopPromises);
options.clearClients();
@@ -52,8 +63,9 @@ export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions)
registerHandler("exit", syncCleanup);
// Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
const signalCleanup = () => void asyncCleanup().catch(() => {});
const signalCleanup = () => void asyncCleanup().catch((error) => {
logCleanupError("signal cleanup failed", error);
});
registerHandler("SIGINT", signalCleanup);
registerHandler("SIGTERM", signalCleanup);
if (process.platform === "win32") {
-3
View File
@@ -2,11 +2,9 @@ import { spawn as bunSpawn } from "bun"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { existsSync, statSync } from "fs"
import { log } from "../../shared/logger"
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
function shouldUseNodeSpawn(): boolean {
return process.platform === "win32"
}
// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798)
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
try {
if (!existsSync(cwd)) {
@@ -24,7 +22,6 @@ export function validateCwd(cwd: string): { valid: boolean; error?: string } {
interface StreamReader {
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
}
// Bridges Bun Subprocess and Node.js ChildProcess under a common API
export interface UnifiedProcess {
stdin: { write(chunk: Uint8Array | string): void }
stdout: { getReader(): StreamReader }
+61
View File
@@ -0,0 +1,61 @@
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
import { sortByScopePriority } from "./scope-priority"
import type { SkillInfo } from "./types"
import type { CommandInfo } from "../slashcommand/types"
function formatSkillCommand(skill: SkillInfo): string {
const lines = [
" <command>",
` <name>/${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <scope>${skill.scope}</scope>`,
]
if (skill.compatibility) {
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
}
lines.push(" </command>")
return lines.join("\n")
}
function formatSlashCommand(command: CommandInfo): string {
const argumentHint = typeof command.metadata.argumentHint === "string"
? command.metadata.argumentHint.trim()
: undefined
const lines = [
" <command>",
` <name>/${command.name}</name>`,
` <description>${command.metadata.description || "(no description)"}</description>`,
` <scope>${command.scope}</scope>`,
]
if (argumentHint) {
lines.push(` <argument>${argumentHint}</argument>`)
}
lines.push(" </command>")
return lines.join("\n")
}
export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
if (skills.length === 0 && commands.length === 0) {
return TOOL_DESCRIPTION_NO_SKILLS
}
const availableItems = [
...sortByScopePriority(skills).map(formatSkillCommand),
...sortByScopePriority(commands).map(formatSlashCommand),
]
if (availableItems.length === 0) {
return TOOL_DESCRIPTION_PREFIX
}
return `${TOOL_DESCRIPTION_PREFIX}
<available_items>
Priority: project > user > opencode > builtin/plugin | Skills listed before commands
Invoke via: skill(name="item-name") — omit leading slash for commands.
${availableItems.join("\n")}
</available_items>`
}
@@ -0,0 +1,96 @@
import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
import type {
SkillMcpClientInfo,
SkillMcpManager,
SkillMcpServerContext,
} from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
export async function formatMcpCapabilities(
skill: LoadedSkill,
manager: SkillMcpManager,
sessionID: string
): Promise<string | null> {
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
return null
}
const sections: string[] = ["", "## Available MCP Servers", ""]
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
const info: SkillMcpClientInfo = {
serverName,
skillName: skill.name,
sessionID,
}
const context: SkillMcpServerContext = {
config,
skillName: skill.name,
}
sections.push(`### ${serverName}`, "")
try {
const [tools, resources, prompts] = await Promise.all([
manager.listTools(info, context).catch(() => []),
manager.listResources(info, context).catch(() => []),
manager.listPrompts(info, context).catch(() => []),
])
appendToolSections(sections, tools as Tool[])
appendResourceSection(sections, resources as Resource[])
appendPromptSection(sections, prompts as Prompt[])
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
sections.push("*No capabilities discovered*")
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
}
sections.push("", `Use \`skill_mcp\` tool with \`mcp_name=\"${serverName}\"\` to invoke.`, "")
}
return sections.join("\n")
}
function appendToolSections(sections: string[], tools: Tool[]): void {
if (tools.length === 0) {
return
}
sections.push("**Tools:**", "")
for (const toolDefinition of tools) {
sections.push(`#### \`${toolDefinition.name}\``)
if (toolDefinition.description) {
sections.push(toolDefinition.description)
}
sections.push(
"",
"**inputSchema:**",
"```json",
JSON.stringify(sanitizeJsonSchema(toolDefinition.inputSchema), null, 2),
"```",
""
)
}
}
function appendResourceSection(sections: string[], resources: Resource[]): void {
if (resources.length === 0) {
return
}
sections.push(`**Resources**: ${resources.map((resource) => resource.uri).join(", ")}`)
}
function appendPromptSection(sections: string[], prompts: Prompt[]): void {
if (prompts.length === 0) {
return
}
sections.push(`**Prompts**: ${prompts.map((prompt) => prompt.name).join(", ")}`)
}
+62
View File
@@ -0,0 +1,62 @@
import type { SkillInfo } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
export type NativeSkillEntry = {
name: string
description: string
location: string
content: string
}
export function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
return {
name: skill.name,
description: skill.definition.description || "",
location: skill.path,
scope: skill.scope,
license: skill.license,
compatibility: skill.compatibility,
metadata: skill.metadata,
allowedTools: skill.allowedTools,
}
}
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
return {
name: native.name,
path: native.location,
definition: {
name: native.name,
description: native.description,
template: native.content,
},
scope: "config",
}
}
export function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skills.map((skill) => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skills.push(nativeSkillToLoadedSkill(native))
knownNames.add(native.name)
}
}
export function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skillInfos.map((skill) => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skillInfos.push({
name: native.name,
description: native.description,
location: native.location,
scope: "config",
})
knownNames.add(native.name)
}
}
export function isPromiseLike<TValue>(value: TValue | Promise<TValue>): value is Promise<TValue> {
return typeof value === "object" && value !== null && "then" in value
}
+17
View File
@@ -0,0 +1,17 @@
export const SCOPE_PRIORITY: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
plugin: 1,
config: 1,
builtin: 1,
}
export function sortByScopePriority<TItem extends { scope: string }>(items: TItem[]): TItem[] {
return [...items].sort((left, right) => {
const leftPriority = SCOPE_PRIORITY[left.scope] || 0
const rightPriority = SCOPE_PRIORITY[right.scope] || 0
return rightPriority - leftPriority
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { LoadedSkill } from "../../features/opencode-skill-loader"
import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
const SKILL_INSTRUCTION_PATTERN = /<skill-instruction>([\s\S]*?)<\/skill-instruction>/
function trimSkillInstruction(template: string): string {
const templateMatch = template.match(SKILL_INSTRUCTION_PATTERN)
return templateMatch ? templateMatch[1].trim() : template
}
export async function extractSkillBody(skill: LoadedSkill): Promise<string> {
if (skill.lazyContent) {
const fullTemplate = await skill.lazyContent.load()
return trimSkillInstruction(fullTemplate)
}
if (skill.scope === "config" && skill.definition.template) {
return trimSkillInstruction(skill.definition.template)
}
if (skill.path) {
return extractSkillTemplate(skill)
}
return trimSkillInstruction(skill.definition.template || "")
}
+40
View File
@@ -0,0 +1,40 @@
import { sortByScopePriority } from "./scope-priority"
import type { CommandInfo } from "../slashcommand/types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
export function matchSkillByName(skills: LoadedSkill[], requestedName: string): LoadedSkill | undefined {
const normalizedName = requestedName.toLowerCase()
const exactMatch = skills.find((skill) => skill.name.toLowerCase() === normalizedName)
if (exactMatch) {
return exactMatch
}
const shortNameMatches = skills.filter((skill) => {
const parts = skill.name.split("/")
const shortName = parts[parts.length - 1]
return parts.length > 1 && shortName?.toLowerCase() === normalizedName
})
if (shortNameMatches.length === 1) {
return shortNameMatches[0]
}
return undefined
}
export function matchCommandByName(commands: CommandInfo[], requestedName: string): CommandInfo | undefined {
const normalizedName = requestedName.toLowerCase()
return sortByScopePriority(commands).find((command) => command.name.toLowerCase() === normalizedName)
}
export function findPartialMatches(
skills: LoadedSkill[],
commands: CommandInfo[],
requestedName: string
): string[] {
const normalizedName = requestedName.toLowerCase()
return [
...skills.map((skill) => skill.name),
...commands.map((command) => `/${command.name}`),
].filter((name) => name.toLowerCase().includes(normalizedName))
}
+3 -3
View File
@@ -732,14 +732,14 @@ describe("skill tool - short name resolution", () => {
]
const tool = createSkillTool({ skills: loadedSkills })
// when / then should not resolve (ambiguous), should suggest both
// when / then, should not resolve (ambiguous), should suggest both
await expect(tool.execute({ name: "debugging" }, mockContext)).rejects.toThrow(
"not found"
)
})
it("prefers exact match over short name match", async () => {
// given "debugging" exists as both exact and as part of a namespace
// given, "debugging" exists as both exact and as part of a namespace
const loadedSkills = [
createMockSkill("debugging"),
createMockSkill("superpowers/debugging"),
@@ -749,7 +749,7 @@ describe("skill tool - short name resolution", () => {
// when
const result = await tool.execute({ name: "debugging" }, mockContext)
// then should match "debugging" exactly, not "superpowers/debugging"
// then, should match "debugging" exactly, not "superpowers/debugging"
expect(result).toContain("## Skill: debugging")
})
})
+34 -266
View File
@@ -1,258 +1,52 @@
import { dirname } from "node:path"
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
import type { SkillArgs, SkillLoadOptions } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
import { getAllSkills, extractSkillTemplate, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
import { discoverCommandsSync } from "../slashcommand/command-discovery"
import type { CommandInfo } from "../slashcommand/types"
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
type NativeSkillEntry = {
name: string
description: string
location: string
content: string
}
// Priority: project > user > opencode/opencode-project > builtin/config
const scopePriority: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
plugin: 1,
config: 1,
builtin: 1,
}
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
return {
name: skill.name,
description: skill.definition.description || "",
location: skill.path,
scope: skill.scope,
license: skill.license,
compatibility: skill.compatibility,
metadata: skill.metadata,
allowedTools: skill.allowedTools,
}
}
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
return {
name: native.name,
path: native.location,
definition: {
name: native.name,
description: native.description,
template: native.content,
},
scope: "config",
}
}
function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skills.map(skill => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skills.push(nativeSkillToLoadedSkill(native))
knownNames.add(native.name)
}
}
function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skillInfos.map(skill => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skillInfos.push({
name: native.name,
description: native.description,
location: native.location,
scope: "config",
})
knownNames.add(native.name)
}
}
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value
}
function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
const lines: string[] = []
if (skills.length === 0 && commands.length === 0) {
return TOOL_DESCRIPTION_NO_SKILLS
}
// Uses module-level scopePriority for consistent priority ordering
const allItems: string[] = []
// Skills rendered as command items (skills are also slash-invocable)
if (skills.length > 0) {
const sortedSkills = [...skills].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA
})
sortedSkills.forEach(skill => {
const parts = [
" <command>",
` <name>/${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <scope>${skill.scope}</scope>`,
]
if (skill.compatibility) {
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
}
parts.push(" </command>")
allItems.push(parts.join("\n"))
})
}
// Sort and add commands second (commands after skills)
if (commands.length > 0) {
const sortedCommands = [...commands].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA // Higher priority first
})
sortedCommands.forEach(cmd => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
const parts = [
" <command>",
` <name>/${cmd.name}</name>`,
` <description>${cmd.metadata.description || "(no description)"}</description>`,
` <scope>${cmd.scope}</scope>`,
]
if (hint) {
parts.push(` <argument>${hint.trim()}</argument>`)
}
parts.push(" </command>")
allItems.push(parts.join("\n"))
})
}
if (allItems.length > 0) {
lines.push(`\n<available_items>\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n</available_items>`)
}
return TOOL_DESCRIPTION_PREFIX + lines.join("")
}
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
if (skill.lazyContent) {
const fullTemplate = await skill.lazyContent.load()
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : fullTemplate
}
if (skill.scope === "config" && skill.definition.template) {
const templateMatch = skill.definition.template.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template
}
if (skill.path) {
return extractSkillTemplate(skill)
}
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
}
async function formatMcpCapabilities(
skill: LoadedSkill,
manager: SkillMcpManager,
sessionID: string
): Promise<string | null> {
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
return null
}
const sections: string[] = ["", "## Available MCP Servers", ""]
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
const info: SkillMcpClientInfo = {
serverName,
skillName: skill.name,
sessionID,
}
const context: SkillMcpServerContext = {
config,
skillName: skill.name,
}
sections.push(`### ${serverName}`)
sections.push("")
try {
const [tools, resources, prompts] = await Promise.all([
manager.listTools(info, context).catch(() => []),
manager.listResources(info, context).catch(() => []),
manager.listPrompts(info, context).catch(() => []),
])
if (tools.length > 0) {
sections.push("**Tools:**")
sections.push("")
for (const t of tools as Tool[]) {
sections.push(`#### \`${t.name}\``)
if (t.description) {
sections.push(t.description)
}
sections.push("")
sections.push("**inputSchema:**")
sections.push("```json")
sections.push(JSON.stringify(sanitizeJsonSchema(t.inputSchema), null, 2))
sections.push("```")
sections.push("")
}
}
if (resources.length > 0) {
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
}
if (prompts.length > 0) {
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
}
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
sections.push("*No capabilities discovered*")
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
}
sections.push("")
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
sections.push("")
}
return sections.join("\n")
}
import { formatCombinedDescription } from "./description-formatter"
import { formatMcpCapabilities } from "./mcp-capability-formatter"
import {
findPartialMatches,
matchCommandByName,
matchSkillByName,
} from "./skill-matcher"
import { extractSkillBody } from "./skill-body"
import {
isPromiseLike,
loadedSkillToInfo,
mergeNativeSkillInfos,
mergeNativeSkills,
} from "./native-skills"
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
let cachedDescription: string | null = null
const getSkills = async (): Promise<LoadedSkill[]> => {
clearSkillCache()
const discovered = await getAllSkills({disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider})
const discovered = await getAllSkills({
disabledSkills: options?.disabledSkills,
browserProvider: options?.browserProvider,
})
const allSkills = !options.skills
? discovered
: [...discovered, ...options.skills.filter(s => !new Set(discovered.map(d => d.name)).has(s.name))]
: [
...discovered,
...options.skills.filter(
(skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name)
),
]
if (options.nativeSkills) {
try {
const nativeAll = await options.nativeSkills.all()
mergeNativeSkills(allSkills, nativeAll)
} catch {
// Native skill discovery may not be available
}
}
@@ -289,7 +83,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
mergeNativeSkillInfos(skillInfos, nativeAll)
}
} catch {
// Native skill discovery may not be available
}
}
@@ -323,21 +116,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
const requestedName = args.name.replace(/^\//, "")
// Check skills first (exact match, case-insensitive)
let matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
// Fallback: try matching by short name (basename) for namespaced skills
// e.g. "systematic-debugging" matches "superpowers/systematic-debugging"
if (!matchedSkill) {
const shortNameMatches = skills.filter(s => {
const parts = s.name.split("/")
return parts.length > 1 && parts[parts.length - 1].toLowerCase() === requestedName.toLowerCase()
})
if (shortNameMatches.length === 1) {
matchedSkill = shortNameMatches[0]
}
}
const matchedSkill = matchSkillByName(skills, requestedName)
if (matchedSkill) {
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
@@ -380,27 +159,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return output.join("\n")
}
// Check commands (exact match, case-insensitive) - sort by priority first
const sortedCommands = [...commands].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA // Higher priority first
})
const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
const matchedCommand = matchCommandByName(commands, requestedName)
if (matchedCommand) {
return await formatLoadedCommand(matchedCommand, args.user_message)
}
// No match found — provide helpful error with partial matches
const allNames = [
...skills.map(s => s.name),
...commands.map(c => `/${c.name}`),
]
const partialMatches = allNames.filter(n =>
n.toLowerCase().includes(requestedName.toLowerCase())
)
const partialMatches = findPartialMatches(skills, commands, requestedName)
if (partialMatches.length > 0) {
throw new Error(
@@ -408,7 +173,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
)
}
const available = allNames.join(", ")
const available = [
...skills.map((skill) => skill.name),
...commands.map((command) => `/${command.name}`),
].join(", ")
throw new Error(
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
)
+1 -1
View File
@@ -535,7 +535,7 @@ describe("syncAllTasksToTodos", () => {
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
// then no duplicates
// then, no duplicates
const matching = writtenTodos.filter((t: TodoInfo) => t.content === "Task 1 (updated)");
expect(matching.length).toBe(1);
expect(matching[0].status).toBe("in_progress");