Merge pull request #3068 from code-yeongyu/refactor/deslop-tools
refactor(tools): split tool constants and skill creators
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -1,308 +1,13 @@
|
|||||||
import type { CategoryConfig } from "../../config/schema"
|
|
||||||
import type {
|
import type {
|
||||||
AvailableCategory,
|
AvailableCategory,
|
||||||
AvailableSkill,
|
AvailableSkill,
|
||||||
} from "../../agents/dynamic-agent-prompt-builder"
|
} from "../../agents/dynamic-agent-prompt-builder"
|
||||||
import { truncateDescription } from "../../shared/truncate-description"
|
import { truncateDescription } from "../../shared/truncate-description"
|
||||||
|
export {
|
||||||
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
CATEGORY_DESCRIPTIONS,
|
||||||
You are working on VISUAL/UI tasks.
|
CATEGORY_PROMPT_APPENDS,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
|
} from "./builtin-categories"
|
||||||
## 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",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System prompt prepended to plan agent invocations.
|
* 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
|
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
||||||
}
|
}
|
||||||
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
|
} 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)!)
|
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
const agentCategoryVariant = agentOverride?.category
|
const agentCategoryVariant = agentOverride?.category
|
||||||
@@ -164,8 +162,6 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
normalizedAgentFallbackModels,
|
normalizedAgentFallbackModels,
|
||||||
defaultProviderID,
|
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)
|
fallbackChain = configuredFallbackChain ?? (resolutionSkipped ? undefined : agentRequirement?.fallbackChain)
|
||||||
|
|
||||||
// Only promote fallback-only settings when resolution actually selected a fallback model.
|
// 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)", () => {
|
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 = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
|
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(
|
const result = applyReplaceLines(
|
||||||
lines,
|
lines,
|
||||||
anchorFor(lines, 1),
|
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(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
|
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")
|
await runFormattersForFile(client, "/project", "/src/main.go")
|
||||||
|
|
||||||
//#then — no error thrown
|
//#then, no error thrown
|
||||||
})
|
})
|
||||||
|
|
||||||
it("runs formatter for matching extension", async () => {
|
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")
|
await runFormattersForFile(client, "/tmp", "/tmp/test.ts")
|
||||||
|
|
||||||
//#then — should complete without error
|
//#then, should complete without error
|
||||||
expect(client.config.get).toHaveBeenCalledTimes(1)
|
expect(client.config.get).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ describe("parseLineRef", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("gives specific hint when literal text is used instead of line number", () => {
|
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"
|
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)
|
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", () => {
|
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"
|
const ref = "LINE42#VK"
|
||||||
|
|
||||||
//#when / #then — should parse successfully as line 42
|
//#when / #then, should parse successfully as line 42
|
||||||
const result = parseLineRef(ref)
|
const result = parseLineRef(ref)
|
||||||
expect(result.line).toBe(42)
|
expect(result.line).toBe(42)
|
||||||
expect(result.hash).toBe("VK")
|
expect(result.hash).toBe("VK")
|
||||||
@@ -144,11 +144,11 @@ describe("validateLineRef", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("suggests correct line number when hash matches a file line", () => {
|
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 lines = ["function hello() {", " return 42", "}"]
|
||||||
const hash = computeLineHash(1, lines[0])
|
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}`))
|
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export function parseLineRef(ref: string): LineRef {
|
|||||||
hash: match[2],
|
hash: match[2],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs
|
|
||||||
const hashIdx = normalized.indexOf('#')
|
const hashIdx = normalized.indexOf('#')
|
||||||
if (hashIdx > 0) {
|
if (hashIdx > 0) {
|
||||||
const prefix = normalized.slice(0, hashIdx)
|
const prefix = normalized.slice(0, hashIdx)
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ describe("isServerInstalled", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
try {
|
try {
|
||||||
rmSync(tempDir, { recursive: true, force: true })
|
rmSync(tempDir, { recursive: true, force: true })
|
||||||
} catch (e) {
|
} catch {
|
||||||
// cleanup failed — ignored
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
type ManagedClientForCleanup = {
|
type ManagedClientForCleanup = {
|
||||||
client: {
|
client: {
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
@@ -22,23 +24,32 @@ export type LspProcessCleanupHandle = {
|
|||||||
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
|
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
|
||||||
const handlers: RegisteredHandler[] = [];
|
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 = () => {
|
const syncCleanup = () => {
|
||||||
for (const [, managed] of options.getClients()) {
|
for (const [, managed] of options.getClients()) {
|
||||||
try {
|
try {
|
||||||
// Fire-and-forget during sync exit - process is terminating
|
void managed.client.stop().catch((error) => {
|
||||||
void managed.client.stop().catch(() => {});
|
logCleanupError("stop failed during exit cleanup", error);
|
||||||
} catch {}
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logCleanupError("failed to schedule exit cleanup", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
options.clearClients();
|
options.clearClients();
|
||||||
options.clearCleanupInterval();
|
options.clearCleanupInterval();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async cleanup for signal handlers - properly await all stops
|
|
||||||
const asyncCleanup = async () => {
|
const asyncCleanup = async () => {
|
||||||
const stopPromises: Promise<void>[] = [];
|
const stopPromises: Promise<void>[] = [];
|
||||||
for (const [, managed] of options.getClients()) {
|
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);
|
await Promise.allSettled(stopPromises);
|
||||||
options.clearClients();
|
options.clearClients();
|
||||||
@@ -52,8 +63,9 @@ export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions)
|
|||||||
|
|
||||||
registerHandler("exit", syncCleanup);
|
registerHandler("exit", syncCleanup);
|
||||||
|
|
||||||
// Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
|
const signalCleanup = () => void asyncCleanup().catch((error) => {
|
||||||
const signalCleanup = () => void asyncCleanup().catch(() => {});
|
logCleanupError("signal cleanup failed", error);
|
||||||
|
});
|
||||||
registerHandler("SIGINT", signalCleanup);
|
registerHandler("SIGINT", signalCleanup);
|
||||||
registerHandler("SIGTERM", signalCleanup);
|
registerHandler("SIGTERM", signalCleanup);
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import { spawn as bunSpawn } from "bun"
|
|||||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
|
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
|
||||||
import { existsSync, statSync } from "fs"
|
import { existsSync, statSync } from "fs"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
|
|
||||||
function shouldUseNodeSpawn(): boolean {
|
function shouldUseNodeSpawn(): boolean {
|
||||||
return process.platform === "win32"
|
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 } {
|
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(cwd)) {
|
if (!existsSync(cwd)) {
|
||||||
@@ -24,7 +22,6 @@ export function validateCwd(cwd: string): { valid: boolean; error?: string } {
|
|||||||
interface StreamReader {
|
interface StreamReader {
|
||||||
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
|
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
|
||||||
}
|
}
|
||||||
// Bridges Bun Subprocess and Node.js ChildProcess under a common API
|
|
||||||
export interface UnifiedProcess {
|
export interface UnifiedProcess {
|
||||||
stdin: { write(chunk: Uint8Array | string): void }
|
stdin: { write(chunk: Uint8Array | string): void }
|
||||||
stdout: { getReader(): StreamReader }
|
stdout: { getReader(): StreamReader }
|
||||||
|
|||||||
@@ -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(", ")}`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 || "")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -732,14 +732,14 @@ describe("skill tool - short name resolution", () => {
|
|||||||
]
|
]
|
||||||
const tool = createSkillTool({ skills: loadedSkills })
|
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(
|
await expect(tool.execute({ name: "debugging" }, mockContext)).rejects.toThrow(
|
||||||
"not found"
|
"not found"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("prefers exact match over short name match", async () => {
|
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 = [
|
const loadedSkills = [
|
||||||
createMockSkill("debugging"),
|
createMockSkill("debugging"),
|
||||||
createMockSkill("superpowers/debugging"),
|
createMockSkill("superpowers/debugging"),
|
||||||
@@ -749,7 +749,7 @@ describe("skill tool - short name resolution", () => {
|
|||||||
// when
|
// when
|
||||||
const result = await tool.execute({ name: "debugging" }, mockContext)
|
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")
|
expect(result).toContain("## Skill: debugging")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+34
-266
@@ -1,258 +1,52 @@
|
|||||||
import { dirname } from "node:path"
|
import { dirname } from "node:path"
|
||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||||
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
|
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||||
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
|
import type { SkillArgs, SkillLoadOptions } from "./types"
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
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 { 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 { discoverCommandsSync } from "../slashcommand/command-discovery"
|
||||||
import type { CommandInfo } from "../slashcommand/types"
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
||||||
|
import { formatCombinedDescription } from "./description-formatter"
|
||||||
type NativeSkillEntry = {
|
import { formatMcpCapabilities } from "./mcp-capability-formatter"
|
||||||
name: string
|
import {
|
||||||
description: string
|
findPartialMatches,
|
||||||
location: string
|
matchCommandByName,
|
||||||
content: string
|
matchSkillByName,
|
||||||
}
|
} from "./skill-matcher"
|
||||||
// Priority: project > user > opencode/opencode-project > builtin/config
|
import { extractSkillBody } from "./skill-body"
|
||||||
const scopePriority: Record<string, number> = {
|
import {
|
||||||
project: 4,
|
isPromiseLike,
|
||||||
user: 3,
|
loadedSkillToInfo,
|
||||||
opencode: 2,
|
mergeNativeSkillInfos,
|
||||||
"opencode-project": 2,
|
mergeNativeSkills,
|
||||||
plugin: 1,
|
} from "./native-skills"
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||||
let cachedDescription: string | null = null
|
let cachedDescription: string | null = null
|
||||||
|
|
||||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||||
clearSkillCache()
|
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
|
const allSkills = !options.skills
|
||||||
? discovered
|
? 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) {
|
if (options.nativeSkills) {
|
||||||
try {
|
try {
|
||||||
const nativeAll = await options.nativeSkills.all()
|
const nativeAll = await options.nativeSkills.all()
|
||||||
mergeNativeSkills(allSkills, nativeAll)
|
mergeNativeSkills(allSkills, nativeAll)
|
||||||
} catch {
|
} catch {
|
||||||
// Native skill discovery may not be available
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +83,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
mergeNativeSkillInfos(skillInfos, nativeAll)
|
mergeNativeSkillInfos(skillInfos, nativeAll)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Native skill discovery may not be available
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,21 +116,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
|
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
|
||||||
|
|
||||||
const requestedName = args.name.replace(/^\//, "")
|
const requestedName = args.name.replace(/^\//, "")
|
||||||
|
const matchedSkill = matchSkillByName(skills, requestedName)
|
||||||
// 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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedSkill) {
|
if (matchedSkill) {
|
||||||
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
|
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")
|
return output.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check commands (exact match, case-insensitive) - sort by priority first
|
const matchedCommand = matchCommandByName(commands, requestedName)
|
||||||
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())
|
|
||||||
|
|
||||||
if (matchedCommand) {
|
if (matchedCommand) {
|
||||||
return await formatLoadedCommand(matchedCommand, args.user_message)
|
return await formatLoadedCommand(matchedCommand, args.user_message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No match found — provide helpful error with partial matches
|
const partialMatches = findPartialMatches(skills, commands, requestedName)
|
||||||
const allNames = [
|
|
||||||
...skills.map(s => s.name),
|
|
||||||
...commands.map(c => `/${c.name}`),
|
|
||||||
]
|
|
||||||
|
|
||||||
const partialMatches = allNames.filter(n =>
|
|
||||||
n.toLowerCase().includes(requestedName.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
if (partialMatches.length > 0) {
|
if (partialMatches.length > 0) {
|
||||||
throw new Error(
|
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(
|
throw new Error(
|
||||||
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
|
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ describe("syncAllTasksToTodos", () => {
|
|||||||
// when
|
// when
|
||||||
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
|
||||||
|
|
||||||
// then — no duplicates
|
// then, no duplicates
|
||||||
const matching = writtenTodos.filter((t: TodoInfo) => t.content === "Task 1 (updated)");
|
const matching = writtenTodos.filter((t: TodoInfo) => t.content === "Task 1 (updated)");
|
||||||
expect(matching.length).toBe(1);
|
expect(matching.length).toBe(1);
|
||||||
expect(matching[0].status).toBe("in_progress");
|
expect(matching[0].status).toBe("in_progress");
|
||||||
|
|||||||
Reference in New Issue
Block a user