mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: Implement intelligent semantic rule injection for context-aware rule selection
- Add SmartRule interface and types for rule metadata - Implement smart rule loading from .roo/smart-rules directories - Create semantic matching using Jaccard similarity algorithm - Integrate smart rules into prompt generation system - Add VSCode configuration settings for smart rules - Support mode-specific smart rules directories - Add comprehensive unit tests for all functionality - Update documentation with smart rules usage Fixes #6707
This commit is contained in:
parent
d90bab71ff
commit
000dcda56e
12 changed files with 1596 additions and 12 deletions
260
docs/smart-rules.md
Normal file
260
docs/smart-rules.md
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
# Smart Rules - Intelligent Semantic Rule Injection
|
||||
|
||||
Smart Rules is a feature that automatically selects and injects relevant rules into the AI context based on the current task, dramatically improving token efficiency and response quality.
|
||||
|
||||
## Overview
|
||||
|
||||
Traditional rule systems inject all rules into every conversation, leading to:
|
||||
|
||||
- **Token waste**: Irrelevant rules consume valuable context tokens
|
||||
- **Context pollution**: Unrelated rules can mislead AI responses
|
||||
- **Poor scalability**: Rule overhead grows linearly with project complexity
|
||||
- **Limited rule depth**: Users avoid detailed rules due to constant overhead
|
||||
|
||||
Smart Rules solves these problems by intelligently matching user queries against rule triggers and only including relevant rules.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Rule Definition**: Create markdown files with YAML frontmatter specifying when rules should apply
|
||||
2. **Semantic Matching**: The system analyzes user queries and calculates similarity scores against rule triggers
|
||||
3. **Automatic Selection**: Only rules exceeding the similarity threshold are included in the context
|
||||
4. **Dependency Resolution**: Rules can specify dependencies that are automatically included
|
||||
|
||||
## Creating Smart Rules
|
||||
|
||||
### File Structure
|
||||
|
||||
Smart rules are stored in special directories within your `.roo` folder:
|
||||
|
||||
```
|
||||
.roo/
|
||||
├── smart-rules/ # General smart rules
|
||||
│ ├── supabase.md
|
||||
│ ├── testing.md
|
||||
│ └── api-design.md
|
||||
└── smart-rules-{mode}/ # Mode-specific smart rules
|
||||
├── database.md
|
||||
└── frontend.md
|
||||
```
|
||||
|
||||
### Rule Format
|
||||
|
||||
Each smart rule is a markdown file with YAML frontmatter:
|
||||
|
||||
````markdown
|
||||
---
|
||||
use-when: "interacting with Supabase client, database queries, or authentication"
|
||||
priority: 10
|
||||
dependencies:
|
||||
- "typescript.md"
|
||||
---
|
||||
|
||||
# Supabase Best Practices
|
||||
|
||||
When working with Supabase:
|
||||
|
||||
## Authentication
|
||||
|
||||
- Always use TypeScript for better type safety
|
||||
- Prefer RLS policies over client-side filtering
|
||||
- Store sensitive configuration in environment variables
|
||||
|
||||
## Database Queries
|
||||
|
||||
```typescript
|
||||
// Good: Type-safe query with error handling
|
||||
const { data, error } = await supabase.from<User>("users").select("*").eq("id", userId).single()
|
||||
|
||||
if (error) throw error
|
||||
```
|
||||
````
|
||||
|
||||
## Real-time Subscriptions
|
||||
|
||||
- Always clean up subscriptions in useEffect cleanup
|
||||
- Use proper TypeScript types for real-time payloads
|
||||
|
||||
````
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
- **use-when** (required): Description of when this rule should be applied. This is matched semantically against user queries.
|
||||
- **priority** (optional): Higher priority rules are selected first when multiple rules match (default: 0)
|
||||
- **dependencies** (optional): Array of other rule filenames that should be included when this rule is selected
|
||||
|
||||
## Configuration
|
||||
|
||||
Smart Rules can be configured in VS Code settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"roo-cline.smartRules.enabled": true,
|
||||
"roo-cline.smartRules.minSimilarity": 0.7,
|
||||
"roo-cline.smartRules.maxRules": 5,
|
||||
"roo-cline.smartRules.showSelectedRules": false,
|
||||
"roo-cline.smartRules.debugRuleSelection": false
|
||||
}
|
||||
````
|
||||
|
||||
### Settings
|
||||
|
||||
- **enabled**: Enable/disable smart rules functionality
|
||||
- **minSimilarity**: Minimum similarity score (0-1) for rule selection. Lower values include more rules.
|
||||
- **maxRules**: Maximum number of smart rules to include in a single prompt
|
||||
- **showSelectedRules**: Display which rules were selected in the UI
|
||||
- **debugRuleSelection**: Enable detailed logging of the rule selection process
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Database Operations
|
||||
|
||||
**User Query**: "Set up Supabase authentication in my Next.js app"
|
||||
|
||||
**Selected Rules**:
|
||||
|
||||
1. `supabase.md` (score: 0.85) - Matched "Supabase" and "authentication"
|
||||
2. `nextjs-app-router.md` (score: 0.72) - Matched "Next.js app"
|
||||
3. `typescript.md` (score: 0.70) - Included as dependency of supabase.md
|
||||
|
||||
### Example 2: API Development
|
||||
|
||||
**User Query**: "Create a REST API endpoint for user management"
|
||||
|
||||
**Selected Rules**:
|
||||
|
||||
1. `api-design.md` (score: 0.88) - Matched "REST API" and "endpoint"
|
||||
2. `testing.md` (score: 0.70) - Included as dependency
|
||||
3. `validation.md` (score: 0.71) - Matched "user management"
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Writing Effective use-when Triggers
|
||||
|
||||
1. **Be Specific**: Include key terms and phrases that users would naturally use
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
use-when: "working with React hooks, useState, useEffect, or custom hooks"
|
||||
|
||||
# Too vague
|
||||
use-when: "React development"
|
||||
```
|
||||
|
||||
2. **Include Variations**: Account for different ways users might describe the same task
|
||||
|
||||
```yaml
|
||||
use-when: "database queries, SQL operations, data fetching, or ORM usage"
|
||||
```
|
||||
|
||||
3. **Use Natural Language**: Write triggers as if describing when a human would need the rule
|
||||
```yaml
|
||||
use-when: "debugging performance issues, optimizing slow code, or profiling applications"
|
||||
```
|
||||
|
||||
### Organizing Rules
|
||||
|
||||
1. **Granular Rules**: Create focused rules for specific topics rather than large, general rules
|
||||
2. **Use Dependencies**: Link related rules instead of duplicating content
|
||||
3. **Mode-Specific Rules**: Place mode-specific rules in `smart-rules-{mode}` directories
|
||||
4. **Prioritize Important Rules**: Use the priority field for rules that should take precedence
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Rule Count**: While there's no hard limit, 50-100 well-organized rules perform well
|
||||
2. **Content Size**: Keep individual rules focused; very large rules still consume tokens when selected
|
||||
3. **Similarity Threshold**: Adjust `minSimilarity` based on your rule specificity:
|
||||
- 0.8-1.0: Very strict matching, fewer rules selected
|
||||
- 0.6-0.8: Balanced matching (recommended)
|
||||
- 0.4-0.6: Loose matching, more rules selected
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Traditional Rules
|
||||
|
||||
1. **Identify Rule Categories**: Group your existing rules by topic or use case
|
||||
2. **Create Smart Rule Files**: Convert each group into a smart rule with appropriate `use-when` trigger
|
||||
3. **Add Dependencies**: Link related rules using the dependencies field
|
||||
4. **Test Selection**: Use debug mode to verify rules are selected appropriately
|
||||
5. **Adjust Triggers**: Refine `use-when` descriptions based on actual usage
|
||||
|
||||
### Example Migration
|
||||
|
||||
**Before** (`.roo/rules/database.md`):
|
||||
|
||||
```markdown
|
||||
# Database Rules
|
||||
|
||||
Always use prepared statements...
|
||||
```
|
||||
|
||||
**After** (`.roo/smart-rules/database.md`):
|
||||
|
||||
```markdown
|
||||
---
|
||||
use-when: "database operations, SQL queries, data persistence, or ORM configuration"
|
||||
priority: 5
|
||||
---
|
||||
|
||||
# Database Rules
|
||||
|
||||
Always use prepared statements...
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rules Not Being Selected
|
||||
|
||||
1. **Check Similarity Score**: Enable `debugRuleSelection` to see similarity scores
|
||||
2. **Lower Threshold**: Temporarily reduce `minSimilarity` to test
|
||||
3. **Improve Triggers**: Add more relevant keywords to `use-when`
|
||||
4. **Verify File Location**: Ensure rules are in correct directories
|
||||
|
||||
### Too Many Rules Selected
|
||||
|
||||
1. **Increase Threshold**: Raise `minSimilarity` value
|
||||
2. **Reduce Max Rules**: Lower `maxRules` setting
|
||||
3. **Refine Triggers**: Make `use-when` descriptions more specific
|
||||
|
||||
### Performance Issues
|
||||
|
||||
1. **Check Rule Count**: Large numbers of rules may slow selection
|
||||
2. **Optimize Content**: Keep rule content focused and concise
|
||||
3. **Review Dependencies**: Avoid circular or excessive dependencies
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Dynamic Rule Generation
|
||||
|
||||
Smart rules can be generated programmatically for large projects:
|
||||
|
||||
```javascript
|
||||
const generateSmartRule = (component, guidelines) => ({
|
||||
filename: `${component.toLowerCase()}.md`,
|
||||
frontmatter: {
|
||||
"use-when": `working with ${component} component, ${component} API, or ${component} configuration`,
|
||||
priority: component.critical ? 10 : 5,
|
||||
},
|
||||
content: guidelines,
|
||||
})
|
||||
```
|
||||
|
||||
### Integration with CI/CD
|
||||
|
||||
Validate smart rules in your pipeline:
|
||||
|
||||
```bash
|
||||
# Check for required frontmatter
|
||||
find .roo/smart-rules -name "*.md" -exec grep -L "use-when:" {} \;
|
||||
|
||||
# Validate YAML frontmatter
|
||||
for file in .roo/smart-rules/*.md; do
|
||||
head -n 20 "$file" | sed -n '/^---$/,/^---$/p' | yaml-lint
|
||||
done
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Machine Learning**: Improve matching using embeddings and vector similarity
|
||||
- **Rule Analytics**: Track which rules are most frequently selected
|
||||
- **Auto-generation**: Generate rules from codebase patterns and documentation
|
||||
- **Rule Sharing**: Community marketplace for smart rule templates
|
||||
312
src/core/prompts/sections/__tests__/smart-rules-loader.spec.ts
Normal file
312
src/core/prompts/sections/__tests__/smart-rules-loader.spec.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { loadSmartRules, hasSmartRules } from "../smart-rules-loader"
|
||||
import * as rooConfig from "../../../../services/roo-config"
|
||||
|
||||
// Mock the modules
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
}))
|
||||
vi.mock("../../../../services/roo-config")
|
||||
vi.mock("../../../../utils/logging", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("smart-rules-loader", () => {
|
||||
const mockCwd = "/test/project"
|
||||
const mockGlobalDir = "/home/user/.roo"
|
||||
const mockProjectDir = "/test/project/.roo"
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock getRooDirectoriesForCwd to return both global and project directories by default
|
||||
vi.mocked(rooConfig.getRooDirectoriesForCwd).mockReturnValue([mockGlobalDir, mockProjectDir])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("loadSmartRules", () => {
|
||||
it("should load smart rules from .roo/smart-rules directory", async () => {
|
||||
// Mock directory existence
|
||||
vi.mocked(fs.stat).mockImplementation(async (path) => {
|
||||
if (path === `${mockProjectDir}/smart-rules`) {
|
||||
return { isDirectory: () => true } as any
|
||||
}
|
||||
throw new Error("Not found")
|
||||
})
|
||||
|
||||
// Mock directory reading
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "rule1.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
{ name: "rule2.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
{ name: "not-markdown.txt", isFile: () => true, isDirectory: () => false } as any,
|
||||
])
|
||||
|
||||
// Mock file reading
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().includes("rule1.md")) {
|
||||
return `---
|
||||
use-when: working with databases
|
||||
priority: 2
|
||||
---
|
||||
# Database Rules
|
||||
Always use prepared statements`
|
||||
}
|
||||
if (filePath.toString().includes("rule2.md")) {
|
||||
return `---
|
||||
use-when: writing tests
|
||||
---
|
||||
# Testing Rules
|
||||
Write tests first`
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
const rules = await loadSmartRules(mockCwd)
|
||||
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(rules[0].filename).toBe("rule1.md")
|
||||
expect(rules[0].useWhen).toBe("working with databases")
|
||||
expect(rules[0].priority).toBe(2)
|
||||
expect(rules[0].content).toContain("Always use prepared statements")
|
||||
|
||||
expect(rules[1].filename).toBe("rule2.md")
|
||||
expect(rules[1].useWhen).toBe("writing tests")
|
||||
expect(rules[1].content).toContain("Write tests first")
|
||||
})
|
||||
|
||||
it("should load mode-specific smart rules", async () => {
|
||||
// Mock directory existence
|
||||
vi.mocked(fs.stat).mockImplementation(async (path) => {
|
||||
if (path === `${mockProjectDir}/smart-rules-code`) {
|
||||
return { isDirectory: () => true } as any
|
||||
}
|
||||
throw new Error("Not found")
|
||||
})
|
||||
|
||||
// Mock directory reading
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "code-rule.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
])
|
||||
|
||||
// Mock file reading
|
||||
vi.mocked(fs.readFile).mockResolvedValue(`---
|
||||
use-when: writing code
|
||||
---
|
||||
# Code Mode Rules
|
||||
Follow coding standards`)
|
||||
|
||||
const rules = await loadSmartRules(mockCwd, "code")
|
||||
|
||||
expect(rules).toHaveLength(1)
|
||||
expect(rules[0].filename).toBe("code-rule.md")
|
||||
expect(rules[0].useWhen).toBe("writing code")
|
||||
})
|
||||
|
||||
it("should merge global and project smart rules", async () => {
|
||||
// Mock directory existence
|
||||
vi.mocked(fs.stat).mockImplementation(async (path) => {
|
||||
if (path === `${mockGlobalDir}/smart-rules` || path === `${mockProjectDir}/smart-rules`) {
|
||||
return { isDirectory: () => true } as any
|
||||
}
|
||||
throw new Error("Not found")
|
||||
})
|
||||
|
||||
// Mock directory reading
|
||||
vi.mocked(fs.readdir).mockImplementation(async (dirPath) => {
|
||||
if (dirPath === `${mockGlobalDir}/smart-rules`) {
|
||||
return [{ name: "global-rule.md", isFile: () => true, isDirectory: () => false } as any]
|
||||
}
|
||||
if (dirPath === `${mockProjectDir}/smart-rules`) {
|
||||
return [{ name: "project-rule.md", isFile: () => true, isDirectory: () => false } as any]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Mock file reading
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().includes("global-rule.md")) {
|
||||
return `---
|
||||
use-when: global rule
|
||||
priority: 1
|
||||
---
|
||||
Global content`
|
||||
}
|
||||
if (filePath.toString().includes("project-rule.md")) {
|
||||
return `---
|
||||
use-when: project rule
|
||||
priority: 2
|
||||
---
|
||||
Project content`
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
const rules = await loadSmartRules(mockCwd)
|
||||
|
||||
expect(rules).toHaveLength(2)
|
||||
// Project rule should come first due to higher priority
|
||||
expect(rules[0].filename).toBe("project-rule.md")
|
||||
expect(rules[1].filename).toBe("global-rule.md")
|
||||
})
|
||||
|
||||
it("should skip files without use-when frontmatter", async () => {
|
||||
// For this test, only return project directory
|
||||
vi.mocked(rooConfig.getRooDirectoriesForCwd).mockReturnValue([mockProjectDir])
|
||||
|
||||
// Mock directory existence - only project dir has smart-rules
|
||||
vi.mocked(fs.stat).mockImplementation(async (path) => {
|
||||
if (path === `${mockProjectDir}/smart-rules`) {
|
||||
return { isDirectory: () => true } as any
|
||||
}
|
||||
throw new Error("Not found")
|
||||
})
|
||||
|
||||
// Mock directory reading - only return files when reading project dir
|
||||
vi.mocked(fs.readdir).mockImplementation(async (dirPath) => {
|
||||
if (dirPath === `${mockProjectDir}/smart-rules`) {
|
||||
return [
|
||||
{ name: "valid.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
{ name: "invalid.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Mock file reading - make sure we return different content for each file
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath, encoding) => {
|
||||
const pathStr = filePath.toString()
|
||||
|
||||
// Valid file with use-when
|
||||
if (pathStr === path.join(mockProjectDir, "smart-rules", "valid.md")) {
|
||||
return `---
|
||||
use-when: valid rule
|
||||
---
|
||||
Valid content`
|
||||
}
|
||||
|
||||
// Invalid file without use-when
|
||||
if (pathStr === path.join(mockProjectDir, "smart-rules", "invalid.md")) {
|
||||
return `---
|
||||
priority: 1
|
||||
---
|
||||
No use-when field`
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected file read: ${filePath}`)
|
||||
})
|
||||
|
||||
const rules = await loadSmartRules(mockCwd)
|
||||
|
||||
// The test should check that only the valid rule is loaded
|
||||
// The invalid.md file should be skipped because it doesn't have use-when
|
||||
expect(rules).toHaveLength(1)
|
||||
expect(rules[0].filename).toBe("valid.md")
|
||||
expect(rules[0].useWhen).toBe("valid rule")
|
||||
expect(rules[0].content).toBe("Valid content")
|
||||
})
|
||||
|
||||
it("should handle nested directories", async () => {
|
||||
// Mock directory existence
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true } as any)
|
||||
|
||||
// Mock directory reading
|
||||
vi.mocked(fs.readdir).mockImplementation(async (dirPath) => {
|
||||
if (dirPath === `${mockProjectDir}/smart-rules`) {
|
||||
return [
|
||||
{ name: "subdir", isFile: () => false, isDirectory: () => true } as any,
|
||||
{ name: "root.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
]
|
||||
}
|
||||
if (dirPath === path.join(`${mockProjectDir}/smart-rules`, "subdir")) {
|
||||
return [{ name: "nested.md", isFile: () => true, isDirectory: () => false } as any]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Mock file reading
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
|
||||
if (filePath.toString().includes("root.md")) {
|
||||
return `---
|
||||
use-when: root rule
|
||||
---
|
||||
Root content`
|
||||
}
|
||||
if (filePath.toString().includes("nested.md")) {
|
||||
return `---
|
||||
use-when: nested rule
|
||||
---
|
||||
Nested content`
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
const rules = await loadSmartRules(mockCwd)
|
||||
|
||||
expect(rules).toHaveLength(2)
|
||||
const filenames = rules.map((r) => r.filename)
|
||||
expect(filenames).toContain("root.md")
|
||||
expect(filenames).toContain("nested.md")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasSmartRules", () => {
|
||||
it("should return true if smart rules exist", async () => {
|
||||
// Mock directory existence
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true } as any)
|
||||
|
||||
// Mock directory reading
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "rule.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
])
|
||||
|
||||
const result = await hasSmartRules(mockCwd)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if no smart rules exist", async () => {
|
||||
// Mock directory doesn't exist
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error("Not found"))
|
||||
|
||||
const result = await hasSmartRules(mockCwd)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should check mode-specific directories when mode is provided", async () => {
|
||||
// Mock directory existence
|
||||
vi.mocked(fs.stat).mockImplementation(async (path) => {
|
||||
if (path === `${mockProjectDir}/smart-rules-code`) {
|
||||
return { isDirectory: () => true } as any
|
||||
}
|
||||
throw new Error("Not found")
|
||||
})
|
||||
|
||||
// Mock directory reading
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: "code-rule.md", isFile: () => true, isDirectory: () => false } as any,
|
||||
])
|
||||
|
||||
const result = await hasSmartRules(mockCwd, "code")
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fs.stat).toHaveBeenCalledWith(`${mockProjectDir}/smart-rules-code`)
|
||||
})
|
||||
})
|
||||
})
|
||||
373
src/core/prompts/sections/__tests__/smart-rules-matcher.spec.ts
Normal file
373
src/core/prompts/sections/__tests__/smart-rules-matcher.spec.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { selectSmartRules, formatSmartRules } from "../smart-rules-matcher"
|
||||
import type { SmartRule, SmartRulesConfig } from "../../types/smart-rules"
|
||||
|
||||
describe("smart-rules-matcher", () => {
|
||||
let mockRules: SmartRule[]
|
||||
let mockConfig: SmartRulesConfig
|
||||
|
||||
beforeEach(() => {
|
||||
mockRules = [
|
||||
{
|
||||
filename: "database.md",
|
||||
useWhen: "working with database queries, SQL operations, or data persistence",
|
||||
priority: 5,
|
||||
dependencies: ["typescript.md"],
|
||||
content: "# Database Rules\nAlways use prepared statements...",
|
||||
},
|
||||
{
|
||||
filename: "typescript.md",
|
||||
useWhen: "TypeScript development, type definitions, or interfaces",
|
||||
priority: 3,
|
||||
dependencies: [],
|
||||
content: "# TypeScript Rules\nUse strict mode...",
|
||||
},
|
||||
{
|
||||
filename: "api-design.md",
|
||||
useWhen: "designing REST APIs, endpoints, or HTTP services",
|
||||
priority: 7,
|
||||
dependencies: ["validation.md"],
|
||||
content: "# API Design Rules\nFollow RESTful conventions...",
|
||||
},
|
||||
{
|
||||
filename: "validation.md",
|
||||
useWhen: "input validation, data sanitization, or security checks",
|
||||
priority: 4,
|
||||
dependencies: [],
|
||||
content: "# Validation Rules\nAlways validate user input...",
|
||||
},
|
||||
{
|
||||
filename: "testing.md",
|
||||
useWhen: "writing tests, unit testing, or test coverage",
|
||||
priority: 2,
|
||||
dependencies: [],
|
||||
content: "# Testing Rules\nAim for 80% coverage...",
|
||||
},
|
||||
]
|
||||
|
||||
mockConfig = {
|
||||
enabled: true,
|
||||
minSimilarity: 0.2, // Lower threshold for tests
|
||||
maxRules: 5,
|
||||
showSelectedRules: false,
|
||||
debugRuleSelection: false,
|
||||
}
|
||||
})
|
||||
|
||||
describe("selectSmartRules", () => {
|
||||
it("should select rules based on query similarity", () => {
|
||||
const query = "How do I create a REST API endpoint?"
|
||||
// Use even lower threshold for this test
|
||||
const testConfig = { ...mockConfig, minSimilarity: 0.05 }
|
||||
const result = selectSmartRules(query, mockRules, testConfig)
|
||||
|
||||
expect(result.rules).toContainEqual(expect.objectContaining({ filename: "api-design.md" }))
|
||||
expect(result.rules.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should include dependencies of selected rules", () => {
|
||||
const query = "How do I design a REST API?"
|
||||
// Use lower threshold for this test
|
||||
const testConfig = { ...mockConfig, minSimilarity: 0.05 }
|
||||
const result = selectSmartRules(query, mockRules, testConfig)
|
||||
|
||||
// Should include api-design.md and its dependency validation.md
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
expect(filenames).toContain("api-design.md")
|
||||
expect(filenames).toContain("validation.md")
|
||||
})
|
||||
|
||||
it("should respect maxRules configuration", () => {
|
||||
const config = { ...mockConfig, maxRules: 2 }
|
||||
const query = "database SQL TypeScript API testing"
|
||||
const result = selectSmartRules(query, mockRules, config)
|
||||
|
||||
// Note: maxRules applies before dependencies, so total might exceed maxRules
|
||||
expect(result.rules.filter((r) => !r.filename.includes("validation.md")).length).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it("should filter rules below minSimilarity threshold", () => {
|
||||
const config = { ...mockConfig, minSimilarity: 0.8 }
|
||||
const query = "unrelated query about something else"
|
||||
const result = selectSmartRules(query, mockRules, config)
|
||||
|
||||
expect(result.rules).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle empty query gracefully", () => {
|
||||
const result = selectSmartRules("", mockRules, mockConfig)
|
||||
expect(result.rules).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle empty rules array", () => {
|
||||
const result = selectSmartRules("test query", [], mockConfig)
|
||||
expect(result.rules).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should prioritize rules with higher priority scores", () => {
|
||||
const query = "API endpoint validation"
|
||||
const result = selectSmartRules(query, mockRules, mockConfig)
|
||||
|
||||
// Both api-design.md and validation.md should match
|
||||
// api-design.md has higher priority (7 vs 4)
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
const apiIndex = filenames.indexOf("api-design.md")
|
||||
const validationIndex = filenames.indexOf("validation.md")
|
||||
|
||||
if (apiIndex !== -1 && validationIndex !== -1) {
|
||||
expect(apiIndex).toBeLessThan(validationIndex)
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle circular dependencies gracefully", () => {
|
||||
const circularRules: SmartRule[] = [
|
||||
{
|
||||
filename: "rule1.md",
|
||||
useWhen: "rule one",
|
||||
priority: 1,
|
||||
dependencies: ["rule2.md"],
|
||||
content: "Rule 1",
|
||||
},
|
||||
{
|
||||
filename: "rule2.md",
|
||||
useWhen: "rule two",
|
||||
priority: 1,
|
||||
dependencies: ["rule1.md"],
|
||||
content: "Rule 2",
|
||||
},
|
||||
]
|
||||
|
||||
const result = selectSmartRules("rule one", circularRules, mockConfig)
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
|
||||
// Should include both rules but not get stuck in infinite loop
|
||||
expect(filenames).toContain("rule1.md")
|
||||
expect(filenames).toContain("rule2.md")
|
||||
expect(result.rules.length).toBe(2)
|
||||
})
|
||||
|
||||
it("should handle missing dependencies gracefully", () => {
|
||||
const rulesWithMissingDeps: SmartRule[] = [
|
||||
{
|
||||
filename: "main.md",
|
||||
useWhen: "main rule",
|
||||
priority: 1,
|
||||
dependencies: ["missing.md"],
|
||||
content: "Main rule",
|
||||
},
|
||||
]
|
||||
|
||||
const result = selectSmartRules("main rule", rulesWithMissingDeps, mockConfig)
|
||||
|
||||
// Should include the main rule even if dependency is missing
|
||||
expect(result.rules).toHaveLength(1)
|
||||
expect(result.rules[0].filename).toBe("main.md")
|
||||
})
|
||||
|
||||
it("should calculate Jaccard similarity correctly", () => {
|
||||
const query = "database SQL queries"
|
||||
const result = selectSmartRules(query, mockRules, mockConfig)
|
||||
|
||||
// database.md should have high similarity
|
||||
const dbRule = result.rules.find((r) => r.filename === "database.md")
|
||||
expect(dbRule).toBeDefined()
|
||||
})
|
||||
|
||||
it("should boost scores for exact phrase matches", () => {
|
||||
const query = "REST APIs and database queries"
|
||||
// Use very low threshold since we're testing phrase matching
|
||||
const testConfig = { ...mockConfig, minSimilarity: 0.01 }
|
||||
const result = selectSmartRules(query, mockRules, testConfig)
|
||||
|
||||
// Both should be selected due to exact phrase matches
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
expect(filenames).toContain("api-design.md") // matches "REST APIs"
|
||||
expect(filenames).toContain("database.md") // matches "database queries"
|
||||
})
|
||||
|
||||
it("should handle case-insensitive matching", () => {
|
||||
const query = "TYPESCRIPT DEVELOPMENT"
|
||||
const result = selectSmartRules(query, mockRules, mockConfig)
|
||||
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
expect(filenames).toContain("typescript.md")
|
||||
})
|
||||
|
||||
it("should deduplicate selected rules", () => {
|
||||
// Create rules where multiple rules have the same dependency
|
||||
const rulesWithSharedDeps: SmartRule[] = [
|
||||
{
|
||||
filename: "feature1.md",
|
||||
useWhen: "feature one implementation",
|
||||
priority: 1,
|
||||
dependencies: ["common.md"],
|
||||
content: "Feature 1",
|
||||
},
|
||||
{
|
||||
filename: "feature2.md",
|
||||
useWhen: "feature two implementation",
|
||||
priority: 1,
|
||||
dependencies: ["common.md"],
|
||||
content: "Feature 2",
|
||||
},
|
||||
{
|
||||
filename: "common.md",
|
||||
useWhen: "common utilities",
|
||||
priority: 1,
|
||||
dependencies: [],
|
||||
content: "Common",
|
||||
},
|
||||
]
|
||||
|
||||
const query = "feature one implementation feature two implementation"
|
||||
const result = selectSmartRules(query, rulesWithSharedDeps, mockConfig)
|
||||
|
||||
// Should include all three rules, but common.md only once
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
const commonCount = filenames.filter((f) => f === "common.md").length
|
||||
expect(commonCount).toBe(1)
|
||||
})
|
||||
|
||||
it("should include reasoning when debugRuleSelection is enabled", () => {
|
||||
const config = { ...mockConfig, debugRuleSelection: true, minSimilarity: 0.1 }
|
||||
const query = "REST API design"
|
||||
const result = selectSmartRules(query, mockRules, config)
|
||||
|
||||
expect(result.reasoning).toBeDefined()
|
||||
expect(result.reasoning!.length).toBeGreaterThan(0)
|
||||
expect(result.reasoning![0]).toHaveProperty("rule")
|
||||
expect(result.reasoning![0]).toHaveProperty("score")
|
||||
expect(result.reasoning![0]).toHaveProperty("reason")
|
||||
})
|
||||
|
||||
it("should include reasoning when showSelectedRules is enabled", () => {
|
||||
const config = { ...mockConfig, showSelectedRules: true, minSimilarity: 0.1 }
|
||||
const query = "database operations"
|
||||
const result = selectSmartRules(query, mockRules, config)
|
||||
|
||||
expect(result.reasoning).toBeDefined()
|
||||
expect(result.reasoning!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should return empty array when disabled", () => {
|
||||
const config = { ...mockConfig, enabled: false }
|
||||
const query = "database SQL"
|
||||
const result = selectSmartRules(query, mockRules, config)
|
||||
|
||||
expect(result.rules).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSmartRules", () => {
|
||||
it("should format selected rules correctly without rule names", () => {
|
||||
const selectedRules: SmartRule[] = [
|
||||
{
|
||||
filename: "rule1.md",
|
||||
useWhen: "test rule",
|
||||
priority: 1,
|
||||
dependencies: [],
|
||||
content: "# Rule 1\nContent of rule 1",
|
||||
},
|
||||
{
|
||||
filename: "rule2.md",
|
||||
useWhen: "another rule",
|
||||
priority: 2,
|
||||
dependencies: [],
|
||||
content: "# Rule 2\nContent of rule 2",
|
||||
},
|
||||
]
|
||||
|
||||
const formatted = formatSmartRules(selectedRules)
|
||||
|
||||
expect(formatted).not.toContain("# Smart Rule from")
|
||||
expect(formatted).toContain("# Rule 1\nContent of rule 1")
|
||||
expect(formatted).toContain("# Rule 2\nContent of rule 2")
|
||||
})
|
||||
|
||||
it("should format selected rules with rule names when showRuleNames is true", () => {
|
||||
const selectedRules: SmartRule[] = [
|
||||
{
|
||||
filename: "rule1.md",
|
||||
useWhen: "test rule",
|
||||
priority: 1,
|
||||
dependencies: [],
|
||||
content: "# Rule 1\nContent of rule 1",
|
||||
},
|
||||
{
|
||||
filename: "rule2.md",
|
||||
useWhen: "another rule",
|
||||
priority: 2,
|
||||
dependencies: [],
|
||||
content: "# Rule 2\nContent of rule 2",
|
||||
},
|
||||
]
|
||||
|
||||
const formatted = formatSmartRules(selectedRules, true)
|
||||
|
||||
expect(formatted).toContain("# Smart Rule from rule1.md:")
|
||||
expect(formatted).toContain("# Rule 1\nContent of rule 1")
|
||||
expect(formatted).toContain("# Smart Rule from rule2.md:")
|
||||
expect(formatted).toContain("# Rule 2\nContent of rule 2")
|
||||
})
|
||||
|
||||
it("should handle empty rules array", () => {
|
||||
const formatted = formatSmartRules([])
|
||||
expect(formatted).toBe("")
|
||||
})
|
||||
|
||||
it("should preserve rule content formatting", () => {
|
||||
const selectedRules: SmartRule[] = [
|
||||
{
|
||||
filename: "formatted.md",
|
||||
useWhen: "formatted content",
|
||||
priority: 1,
|
||||
dependencies: [],
|
||||
content: "# Header\n\n```typescript\nconst x = 1;\n```\n\n- List item\n- Another item",
|
||||
},
|
||||
]
|
||||
|
||||
const formatted = formatSmartRules(selectedRules)
|
||||
|
||||
expect(formatted).toContain("```typescript")
|
||||
expect(formatted).toContain("const x = 1;")
|
||||
expect(formatted).toContain("- List item")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle rules with empty useWhen", () => {
|
||||
const rulesWithEmptyUseWhen: SmartRule[] = [
|
||||
{
|
||||
filename: "empty.md",
|
||||
useWhen: "",
|
||||
priority: 1,
|
||||
dependencies: [],
|
||||
content: "Empty use-when",
|
||||
},
|
||||
]
|
||||
|
||||
const result = selectSmartRules("test query", rulesWithEmptyUseWhen, mockConfig)
|
||||
expect(result.rules).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle very long queries", () => {
|
||||
const longQuery = "database ".repeat(100) + "SQL"
|
||||
const config = { ...mockConfig, minSimilarity: 0.1 } // Lower threshold for long query
|
||||
const result = selectSmartRules(longQuery, mockRules, config)
|
||||
|
||||
// Should still match database.md
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
expect(filenames).toContain("database.md")
|
||||
})
|
||||
|
||||
it("should handle special characters in queries", () => {
|
||||
const specialQuery = "REST API @#$%^&*() endpoint!"
|
||||
const config = { ...mockConfig, minSimilarity: 0.1 } // Lower threshold for special chars
|
||||
const result = selectSmartRules(specialQuery, mockRules, config)
|
||||
|
||||
// Should still match api-design.md
|
||||
const filenames = result.rules.map((r) => r.filename)
|
||||
expect(filenames).toContain("api-design.md")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -6,9 +6,12 @@ import { Dirent } from "fs"
|
|||
import { isLanguage } from "@roo-code/types"
|
||||
|
||||
import type { SystemPromptSettings } from "../types"
|
||||
import type { SmartRulesConfig } from "../types/smart-rules"
|
||||
|
||||
import { LANGUAGES } from "../../../shared/language"
|
||||
import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config"
|
||||
import { loadSmartRules, hasSmartRules } from "./smart-rules-loader"
|
||||
import { selectSmartRules, formatSmartRules } from "./smart-rules-matcher"
|
||||
|
||||
/**
|
||||
* Safely read a file and return its trimmed content
|
||||
|
|
@ -264,6 +267,8 @@ export async function addCustomInstructions(
|
|||
language?: string
|
||||
rooIgnoreInstructions?: string
|
||||
settings?: SystemPromptSettings
|
||||
userQuery?: string
|
||||
smartRulesConfig?: Partial<SmartRulesConfig>
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const sections = []
|
||||
|
|
@ -356,6 +361,24 @@ export async function addCustomInstructions(
|
|||
rules.push(genericRuleContent.trim())
|
||||
}
|
||||
|
||||
// Add smart rules if enabled and user query is provided
|
||||
if (options.userQuery && options.smartRulesConfig?.enabled !== false) {
|
||||
const availableSmartRules = await loadSmartRules(cwd, mode)
|
||||
if (availableSmartRules.length > 0) {
|
||||
const selectionResult = selectSmartRules(options.userQuery, availableSmartRules, options.smartRulesConfig)
|
||||
|
||||
if (selectionResult.rules.length > 0) {
|
||||
const formattedSmartRules = formatSmartRules(
|
||||
selectionResult.rules,
|
||||
options.smartRulesConfig?.showSelectedRules,
|
||||
)
|
||||
if (formattedSmartRules) {
|
||||
rules.push(formattedSmartRules)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.length > 0) {
|
||||
sections.push(`Rules:\n\n${rules.join("\n\n")}`)
|
||||
}
|
||||
|
|
|
|||
204
src/core/prompts/sections/smart-rules-loader.ts
Normal file
204
src/core/prompts/sections/smart-rules-loader.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import * as yaml from "yaml"
|
||||
|
||||
import type { SmartRule, SmartRuleFile } from "../types/smart-rules"
|
||||
import { getRooDirectoriesForCwd } from "../../../services/roo-config"
|
||||
import { logger } from "../../../utils/logging"
|
||||
|
||||
/**
|
||||
* Check if a directory exists
|
||||
*/
|
||||
async function directoryExists(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.stat(dirPath)
|
||||
return stats.isDirectory()
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from a markdown file
|
||||
* @param content The file content
|
||||
* @returns Object containing frontmatter data and markdown content
|
||||
*/
|
||||
function parseFrontmatter(content: string): { frontmatter: SmartRuleFile | null; markdown: string } {
|
||||
const lines = content.split("\n")
|
||||
|
||||
// Check if file starts with frontmatter delimiter
|
||||
if (lines[0] !== "---") {
|
||||
return { frontmatter: null, markdown: content }
|
||||
}
|
||||
|
||||
// Find the closing delimiter
|
||||
let endIndex = -1
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i] === "---") {
|
||||
endIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// No closing delimiter found
|
||||
if (endIndex === -1) {
|
||||
return { frontmatter: null, markdown: content }
|
||||
}
|
||||
|
||||
// Extract and parse frontmatter
|
||||
const frontmatterContent = lines.slice(1, endIndex).join("\n")
|
||||
const markdownContent = lines
|
||||
.slice(endIndex + 1)
|
||||
.join("\n")
|
||||
.trim()
|
||||
|
||||
try {
|
||||
const frontmatter = yaml.parse(frontmatterContent) as SmartRuleFile
|
||||
return { frontmatter, markdown: markdownContent }
|
||||
} catch (error) {
|
||||
logger.error("Failed to parse frontmatter", { error })
|
||||
return { frontmatter: null, markdown: content }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a smart rule from a file
|
||||
* @param filePath The path to the rule file
|
||||
* @returns The parsed smart rule or null if invalid
|
||||
*/
|
||||
async function loadSmartRuleFromFile(filePath: string): Promise<SmartRule | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
const { frontmatter, markdown } = parseFrontmatter(content)
|
||||
|
||||
// Skip files without frontmatter or use-when field
|
||||
if (!frontmatter || !frontmatter["use-when"]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filename = path.basename(filePath)
|
||||
|
||||
return {
|
||||
filename,
|
||||
useWhen: frontmatter["use-when"],
|
||||
content: markdown,
|
||||
priority: frontmatter.priority,
|
||||
dependencies: frontmatter.dependencies,
|
||||
metadata: Object.fromEntries(
|
||||
Object.entries(frontmatter).filter(([key]) => !["use-when", "priority", "dependencies"].includes(key)),
|
||||
),
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to load smart rule from file", { filePath, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively load smart rules from a directory
|
||||
* @param dirPath The directory path
|
||||
* @returns Array of loaded smart rules
|
||||
*/
|
||||
async function loadSmartRulesFromDirectory(dirPath: string): Promise<SmartRule[]> {
|
||||
const rules: SmartRule[] = []
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively load from subdirectories
|
||||
const subRules = await loadSmartRulesFromDirectory(fullPath)
|
||||
rules.push(...subRules)
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
// Load rule from markdown file
|
||||
const rule = await loadSmartRuleFromFile(fullPath)
|
||||
if (rule) {
|
||||
rules.push(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to load smart rules from directory", { dirPath, error })
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all smart rules from the appropriate directories
|
||||
* @param cwd The current working directory
|
||||
* @param mode The current mode (optional, for mode-specific smart rules)
|
||||
* @returns Array of all loaded smart rules
|
||||
*/
|
||||
export async function loadSmartRules(cwd: string, mode?: string): Promise<SmartRule[]> {
|
||||
const allRules: SmartRule[] = []
|
||||
const rooDirectories = getRooDirectoriesForCwd(cwd)
|
||||
|
||||
// Load global and project smart rules
|
||||
for (const rooDir of rooDirectories) {
|
||||
const smartRulesDir = path.join(rooDir, "smart-rules")
|
||||
if (await directoryExists(smartRulesDir)) {
|
||||
const rules = await loadSmartRulesFromDirectory(smartRulesDir)
|
||||
allRules.push(...rules)
|
||||
}
|
||||
}
|
||||
|
||||
// Load mode-specific smart rules if mode is provided
|
||||
if (mode) {
|
||||
for (const rooDir of rooDirectories) {
|
||||
const modeSmartRulesDir = path.join(rooDir, `smart-rules-${mode}`)
|
||||
if (await directoryExists(modeSmartRulesDir)) {
|
||||
const rules = await loadSmartRulesFromDirectory(modeSmartRulesDir)
|
||||
allRules.push(...rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority (higher priority first) and then by filename for stability
|
||||
allRules.sort((a, b) => {
|
||||
const priorityDiff = (b.priority ?? 0) - (a.priority ?? 0)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return a.filename.localeCompare(b.filename)
|
||||
})
|
||||
|
||||
return allRules
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if smart rules are available for the current context
|
||||
* @param cwd The current working directory
|
||||
* @param mode The current mode (optional)
|
||||
* @returns True if any smart rules exist
|
||||
*/
|
||||
export async function hasSmartRules(cwd: string, mode?: string): Promise<boolean> {
|
||||
const rooDirectories = getRooDirectoriesForCwd(cwd)
|
||||
|
||||
// Check for general smart rules
|
||||
for (const rooDir of rooDirectories) {
|
||||
const smartRulesDir = path.join(rooDir, "smart-rules")
|
||||
if (await directoryExists(smartRulesDir)) {
|
||||
const entries = await fs.readdir(smartRulesDir, { withFileTypes: true })
|
||||
if (entries.some((e) => e.isFile() && e.name.endsWith(".md"))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for mode-specific smart rules
|
||||
if (mode) {
|
||||
for (const rooDir of rooDirectories) {
|
||||
const modeSmartRulesDir = path.join(rooDir, `smart-rules-${mode}`)
|
||||
if (await directoryExists(modeSmartRulesDir)) {
|
||||
const entries = await fs.readdir(modeSmartRulesDir, { withFileTypes: true })
|
||||
if (entries.some((e) => e.isFile() && e.name.endsWith(".md"))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
192
src/core/prompts/sections/smart-rules-matcher.ts
Normal file
192
src/core/prompts/sections/smart-rules-matcher.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import type { SmartRule, SmartRuleSelectionResult, SmartRulesConfig } from "../types/smart-rules"
|
||||
import { logger } from "../../../utils/logging"
|
||||
|
||||
/**
|
||||
* Default configuration for smart rules
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<SmartRulesConfig> = {
|
||||
enabled: true,
|
||||
minSimilarity: 0.7,
|
||||
maxRules: 5,
|
||||
showSelectedRules: false,
|
||||
debugRuleSelection: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize text for similarity comparison
|
||||
* @param text The text to tokenize
|
||||
* @returns Array of normalized tokens
|
||||
*/
|
||||
function tokenize(text: string): string[] {
|
||||
// Convert to lowercase and split on word boundaries
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, " ") // Replace punctuation with spaces
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 2) // Filter out very short tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Jaccard similarity between two sets of tokens
|
||||
* @param tokens1 First set of tokens
|
||||
* @param tokens2 Second set of tokens
|
||||
* @returns Similarity score between 0 and 1
|
||||
*/
|
||||
function jaccardSimilarity(tokens1: string[], tokens2: string[]): number {
|
||||
const set1 = new Set(tokens1)
|
||||
const set2 = new Set(tokens2)
|
||||
|
||||
// Calculate intersection
|
||||
const intersection = new Set()
|
||||
set1.forEach((item) => {
|
||||
if (set2.has(item)) {
|
||||
intersection.add(item)
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate union
|
||||
const union = new Set(set1)
|
||||
set2.forEach((item) => union.add(item))
|
||||
|
||||
if (union.size === 0) return 0
|
||||
return intersection.size / union.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate semantic similarity between query and rule trigger
|
||||
* This is a simple implementation that can be enhanced with more sophisticated NLP
|
||||
* @param query The user query
|
||||
* @param trigger The rule trigger text
|
||||
* @returns Similarity score between 0 and 1
|
||||
*/
|
||||
function calculateSimilarity(query: string, trigger: string): number {
|
||||
const queryTokens = tokenize(query)
|
||||
const triggerTokens = tokenize(trigger)
|
||||
|
||||
// Calculate Jaccard similarity
|
||||
const jaccardScore = jaccardSimilarity(queryTokens, triggerTokens)
|
||||
|
||||
// Check for key phrase matches (boost score if trigger phrases appear in query)
|
||||
const triggerPhrases = trigger
|
||||
.toLowerCase()
|
||||
.split(/,|\sor\s/)
|
||||
.map((phrase) => phrase.trim())
|
||||
.filter((phrase) => phrase.length > 0)
|
||||
|
||||
let phraseMatchBoost = 0
|
||||
const queryLower = query.toLowerCase()
|
||||
for (const phrase of triggerPhrases) {
|
||||
if (queryLower.includes(phrase)) {
|
||||
phraseMatchBoost += 0.3
|
||||
}
|
||||
}
|
||||
|
||||
// Combine scores (cap at 1.0)
|
||||
return Math.min(1.0, jaccardScore + phraseMatchBoost)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select smart rules based on user query
|
||||
* @param query The user query/task description
|
||||
* @param availableRules All available smart rules
|
||||
* @param config Smart rules configuration
|
||||
* @returns Selection result with matched rules and reasoning
|
||||
*/
|
||||
export function selectSmartRules(
|
||||
query: string,
|
||||
availableRules: SmartRule[],
|
||||
config: Partial<SmartRulesConfig> = {},
|
||||
): SmartRuleSelectionResult {
|
||||
const finalConfig = { ...DEFAULT_CONFIG, ...config }
|
||||
|
||||
if (!finalConfig.enabled || availableRules.length === 0) {
|
||||
return { rules: [] }
|
||||
}
|
||||
|
||||
// Calculate similarity scores for all rules
|
||||
const scoredRules = availableRules.map((rule) => {
|
||||
const score = calculateSimilarity(query, rule.useWhen)
|
||||
return { rule, score }
|
||||
})
|
||||
|
||||
// Filter by minimum similarity and sort by score (descending)
|
||||
const eligibleRules = scoredRules
|
||||
.filter(({ score }) => score >= finalConfig.minSimilarity)
|
||||
.sort((a, b) => {
|
||||
// First sort by score
|
||||
const scoreDiff = b.score - a.score
|
||||
if (scoreDiff !== 0) return scoreDiff
|
||||
// Then by priority
|
||||
const priorityDiff = (b.rule.priority ?? 0) - (a.rule.priority ?? 0)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
// Finally by filename for stability
|
||||
return a.rule.filename.localeCompare(b.rule.filename)
|
||||
})
|
||||
.slice(0, finalConfig.maxRules)
|
||||
|
||||
// Collect selected rules and handle dependencies
|
||||
const selectedRules = new Map<string, SmartRule>()
|
||||
const reasoning: SmartRuleSelectionResult["reasoning"] = []
|
||||
|
||||
for (const { rule, score } of eligibleRules) {
|
||||
selectedRules.set(rule.filename, rule)
|
||||
|
||||
if (finalConfig.debugRuleSelection || finalConfig.showSelectedRules) {
|
||||
reasoning.push({
|
||||
rule: rule.filename,
|
||||
score: Math.round(score * 100) / 100,
|
||||
reason: `Matched "${rule.useWhen}" with score ${(score * 100).toFixed(0)}%`,
|
||||
})
|
||||
}
|
||||
|
||||
// Add dependencies
|
||||
if (rule.dependencies) {
|
||||
for (const depFilename of rule.dependencies) {
|
||||
const depRule = availableRules.find((r) => r.filename === depFilename)
|
||||
if (depRule && !selectedRules.has(depFilename)) {
|
||||
selectedRules.set(depFilename, depRule)
|
||||
|
||||
if (finalConfig.debugRuleSelection || finalConfig.showSelectedRules) {
|
||||
reasoning.push({
|
||||
rule: depFilename,
|
||||
score: 0,
|
||||
reason: `Included as dependency of ${rule.filename}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log selection results if debugging is enabled
|
||||
if (finalConfig.debugRuleSelection) {
|
||||
logger.info("Smart rule selection completed", {
|
||||
query: query.substring(0, 100) + "...",
|
||||
totalRules: availableRules.length,
|
||||
selectedCount: selectedRules.size,
|
||||
reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
rules: Array.from(selectedRules.values()),
|
||||
reasoning: reasoning.length > 0 ? reasoning : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format selected smart rules for inclusion in the prompt
|
||||
* @param rules The selected smart rules
|
||||
* @param showRuleNames Whether to include rule filenames in the output
|
||||
* @returns Formatted rules content
|
||||
*/
|
||||
export function formatSmartRules(rules: SmartRule[], showRuleNames = false): string {
|
||||
if (rules.length === 0) return ""
|
||||
|
||||
const sections = rules.map((rule) => {
|
||||
const header = showRuleNames ? `# Smart Rule from ${rule.filename}:\n` : ""
|
||||
return header + rule.content
|
||||
})
|
||||
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import * as os from "os"
|
|||
import type { ModeConfig, PromptComponent, CustomModePrompts, TodoItem } from "@roo-code/types"
|
||||
|
||||
import type { SystemPromptSettings } from "./types"
|
||||
import type { SmartRulesConfig } from "./types/smart-rules"
|
||||
|
||||
import { Mode, modes, defaultModeSlug, getModeBySlug, getGroupName, getModeSelection } from "../../shared/modes"
|
||||
import { DiffStrategy } from "../../shared/tools"
|
||||
|
|
@ -61,6 +62,8 @@ async function generatePrompt(
|
|||
partialReadsEnabled?: boolean,
|
||||
settings?: SystemPromptSettings,
|
||||
todoList?: TodoItem[],
|
||||
userQuery?: string,
|
||||
smartRulesConfig?: Partial<SmartRulesConfig>,
|
||||
): Promise<string> {
|
||||
if (!context) {
|
||||
throw new Error("Extension context is required for generating system prompt")
|
||||
|
|
@ -126,6 +129,8 @@ ${await addCustomInstructions(baseInstructions, globalCustomInstructions || "",
|
|||
language: language ?? formatLanguage(vscode.env.language),
|
||||
rooIgnoreInstructions,
|
||||
settings,
|
||||
userQuery,
|
||||
smartRulesConfig,
|
||||
})}`
|
||||
|
||||
return basePrompt
|
||||
|
|
@ -150,6 +155,8 @@ export const SYSTEM_PROMPT = async (
|
|||
partialReadsEnabled?: boolean,
|
||||
settings?: SystemPromptSettings,
|
||||
todoList?: TodoItem[],
|
||||
userQuery?: string,
|
||||
smartRulesConfig?: Partial<SmartRulesConfig>,
|
||||
): Promise<string> => {
|
||||
if (!context) {
|
||||
throw new Error("Extension context is required for generating system prompt")
|
||||
|
|
@ -188,6 +195,8 @@ export const SYSTEM_PROMPT = async (
|
|||
language: language ?? formatLanguage(vscode.env.language),
|
||||
rooIgnoreInstructions,
|
||||
settings,
|
||||
userQuery,
|
||||
smartRulesConfig,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -221,5 +230,7 @@ ${customInstructions}`
|
|||
partialReadsEnabled,
|
||||
settings,
|
||||
todoList,
|
||||
userQuery,
|
||||
smartRulesConfig,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
118
src/core/prompts/types/smart-rules.ts
Normal file
118
src/core/prompts/types/smart-rules.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Smart Rules types and interfaces for intelligent semantic rule injection
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a smart rule with context triggers and content
|
||||
*/
|
||||
export interface SmartRule {
|
||||
/**
|
||||
* The filename where this rule was loaded from
|
||||
*/
|
||||
filename: string
|
||||
|
||||
/**
|
||||
* The context trigger that describes when this rule should be used
|
||||
* This is matched against user queries to determine relevance
|
||||
*/
|
||||
useWhen: string
|
||||
|
||||
/**
|
||||
* The actual rule content (markdown)
|
||||
*/
|
||||
content: string
|
||||
|
||||
/**
|
||||
* Optional priority for rule ordering (higher = more important)
|
||||
* Default is 0
|
||||
*/
|
||||
priority?: number
|
||||
|
||||
/**
|
||||
* Optional dependencies - other rules that should be included when this rule is selected
|
||||
*/
|
||||
dependencies?: string[]
|
||||
|
||||
/**
|
||||
* Optional metadata for future extensibility
|
||||
*/
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for smart rules
|
||||
*/
|
||||
export interface SmartRulesConfig {
|
||||
/**
|
||||
* Whether smart rules are enabled
|
||||
*/
|
||||
enabled: boolean
|
||||
|
||||
/**
|
||||
* Minimum similarity score for rule matching (0-1)
|
||||
* Default is 0.7
|
||||
*/
|
||||
minSimilarity?: number
|
||||
|
||||
/**
|
||||
* Maximum number of smart rules to include in a single prompt
|
||||
* Default is 5
|
||||
*/
|
||||
maxRules?: number
|
||||
|
||||
/**
|
||||
* Whether to show which rules were selected in the UI
|
||||
* Default is false
|
||||
*/
|
||||
showSelectedRules?: boolean
|
||||
|
||||
/**
|
||||
* Whether to include rule selection reasoning in debug logs
|
||||
* Default is false
|
||||
*/
|
||||
debugRuleSelection?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of smart rule selection
|
||||
*/
|
||||
export interface SmartRuleSelectionResult {
|
||||
/**
|
||||
* The selected rules
|
||||
*/
|
||||
rules: SmartRule[]
|
||||
|
||||
/**
|
||||
* Reasoning for why each rule was selected (for debugging/transparency)
|
||||
*/
|
||||
reasoning?: Array<{
|
||||
rule: string
|
||||
score: number
|
||||
reason: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart rule file format (YAML frontmatter + markdown content)
|
||||
*/
|
||||
export interface SmartRuleFile {
|
||||
/**
|
||||
* When to use this rule (from frontmatter)
|
||||
*/
|
||||
"use-when": string
|
||||
|
||||
/**
|
||||
* Optional priority (from frontmatter)
|
||||
*/
|
||||
priority?: number
|
||||
|
||||
/**
|
||||
* Optional dependencies (from frontmatter)
|
||||
*/
|
||||
dependencies?: string[]
|
||||
|
||||
/**
|
||||
* Any other metadata fields
|
||||
*/
|
||||
[key: string]: any
|
||||
}
|
||||
|
|
@ -1842,7 +1842,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
|
||||
private async getSystemPrompt(): Promise<string> {
|
||||
private async getSystemPrompt(userQuery?: string): Promise<string> {
|
||||
const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
let mcpHub: McpHub | undefined
|
||||
if (mcpEnabled ?? true) {
|
||||
|
|
@ -1913,6 +1913,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
|
||||
useAgentRules: vscode.workspace.getConfiguration("roo-cline").get<boolean>("useAgentRules") ?? true,
|
||||
},
|
||||
undefined, // todoList
|
||||
userQuery,
|
||||
{
|
||||
enabled: vscode.workspace.getConfiguration("roo-cline").get<boolean>("smartRules.enabled") ?? true,
|
||||
minSimilarity:
|
||||
vscode.workspace.getConfiguration("roo-cline").get<number>("smartRules.minSimilarity") ?? 0.7,
|
||||
maxRules: vscode.workspace.getConfiguration("roo-cline").get<number>("smartRules.maxRules") ?? 5,
|
||||
showSelectedRules:
|
||||
vscode.workspace.getConfiguration("roo-cline").get<boolean>("smartRules.showSelectedRules") ??
|
||||
false,
|
||||
debugRuleSelection:
|
||||
vscode.workspace.getConfiguration("roo-cline").get<boolean>("smartRules.debugRuleSelection") ??
|
||||
false,
|
||||
},
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
|
@ -1980,7 +1994,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// requests — even from new subtasks — will honour the provider's rate-limit.
|
||||
Task.lastGlobalApiRequestTime = Date.now()
|
||||
|
||||
const systemPrompt = await this.getSystemPrompt()
|
||||
// Extract the user query from the most recent user message
|
||||
let userQuery: string | undefined
|
||||
const lastUserMessage = this.apiConversationHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "user")
|
||||
|
||||
if (lastUserMessage && Array.isArray(lastUserMessage.content)) {
|
||||
const textBlocks = lastUserMessage.content.filter((block: any) => block.type === "text")
|
||||
if (textBlocks.length > 0) {
|
||||
// Combine all text blocks to form the user query
|
||||
userQuery = textBlocks.map((block: any) => block.text).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = await this.getSystemPrompt(userQuery)
|
||||
const { contextTokens } = this.getTokenUsage()
|
||||
|
||||
if (contextTokens) {
|
||||
|
|
|
|||
|
|
@ -391,6 +391,35 @@
|
|||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%settings.useAgentRules.description%"
|
||||
},
|
||||
"roo-cline.smartRules.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%settings.smartRules.enabled.description%"
|
||||
},
|
||||
"roo-cline.smartRules.minSimilarity": {
|
||||
"type": "number",
|
||||
"default": 0.7,
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "%settings.smartRules.minSimilarity.description%"
|
||||
},
|
||||
"roo-cline.smartRules.maxRules": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "%settings.smartRules.maxRules.description%"
|
||||
},
|
||||
"roo-cline.smartRules.showSelectedRules": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%settings.smartRules.showSelectedRules.description%"
|
||||
},
|
||||
"roo-cline.smartRules.debugRuleSelection": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%settings.smartRules.debugRuleSelection.description%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,5 +37,10 @@
|
|||
"settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')",
|
||||
"settings.enableCodeActions.description": "Enable Roo Code quick fixes",
|
||||
"settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.",
|
||||
"settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)"
|
||||
"settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)",
|
||||
"settings.smartRules.enabled.description": "Enable intelligent semantic rule injection that automatically selects relevant rules based on the current task",
|
||||
"settings.smartRules.minSimilarity.description": "Minimum similarity score (0-1) required for a smart rule to be selected. Lower values include more rules",
|
||||
"settings.smartRules.maxRules.description": "Maximum number of smart rules to include in a single prompt",
|
||||
"settings.smartRules.showSelectedRules.description": "Show which smart rules were selected in the UI",
|
||||
"settings.smartRules.debugRuleSelection.description": "Enable debug logging for smart rule selection process"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,26 @@ import {
|
|||
DEFAULT_MODES,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { addCustomInstructions } from "../core/prompts/sections/custom-instructions"
|
||||
|
||||
import { EXPERIMENT_IDS } from "./experiments"
|
||||
import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "./tools"
|
||||
|
||||
// Conditional import to avoid bundling Node.js modules in webview
|
||||
let addCustomInstructions:
|
||||
| typeof import("../core/prompts/sections/custom-instructions").addCustomInstructions
|
||||
| undefined
|
||||
|
||||
// Dynamic import for Node.js environment
|
||||
async function loadAddCustomInstructions() {
|
||||
if (typeof window === "undefined" && !addCustomInstructions) {
|
||||
try {
|
||||
const module = await import("../core/prompts/sections/custom-instructions")
|
||||
addCustomInstructions = module.addCustomInstructions
|
||||
} catch (error) {
|
||||
console.error("Failed to load addCustomInstructions:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Mode = string
|
||||
|
||||
// Helper to extract group name regardless of format
|
||||
|
|
@ -307,6 +322,10 @@ export async function getFullModeDetails(
|
|||
cwd?: string
|
||||
globalCustomInstructions?: string
|
||||
language?: string
|
||||
rooIgnoreInstructions?: string
|
||||
settings?: any
|
||||
userQuery?: string
|
||||
smartRulesConfig?: any
|
||||
},
|
||||
): Promise<ModeConfig> {
|
||||
// First get the base mode config from custom modes or built-in modes
|
||||
|
|
@ -323,13 +342,22 @@ export async function getFullModeDetails(
|
|||
// If we have cwd, load and combine all custom instructions
|
||||
let fullCustomInstructions = baseCustomInstructions
|
||||
if (options?.cwd) {
|
||||
fullCustomInstructions = await addCustomInstructions(
|
||||
baseCustomInstructions,
|
||||
options.globalCustomInstructions || "",
|
||||
options.cwd,
|
||||
modeSlug,
|
||||
{ language: options.language },
|
||||
)
|
||||
await loadAddCustomInstructions()
|
||||
if (addCustomInstructions) {
|
||||
fullCustomInstructions = await addCustomInstructions(
|
||||
baseCustomInstructions,
|
||||
options.globalCustomInstructions || "",
|
||||
options.cwd,
|
||||
modeSlug,
|
||||
{
|
||||
language: options.language,
|
||||
rooIgnoreInstructions: options.rooIgnoreInstructions,
|
||||
settings: options.settings,
|
||||
userQuery: options.userQuery,
|
||||
smartRulesConfig: options.smartRulesConfig,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Return mode with any overrides applied
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue