Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions src/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ class Agent {
this.scanOnFirstRequest = options.scanOnFirstRequest !== false; // Default to true
this.gitEnabled = options.gitEnabled || false; // Git auto-commit feature (opt-in)
this.maxHistoryLength = options.maxHistoryLength || 10; // Max conversation turns to keep
this.customPrompt = null; // Custom system prompt from Coderrr.md
this.skillsPrompt = null; // Skills prompt from Skills.md (persistent skills)
this.customPrompt = null; // Custom system prompt from Coderrr.md (task-specific)

// Initialize project-local storage and load cross-session memory
configManager.initializeProjectStorage(this.workingDir);
Expand Down Expand Up @@ -84,15 +85,32 @@ class Agent {
ui.info('Conversation history cleared');
}

/**
* Load skills prompt from Skills.md in project directory
* Skills are persistent guidance that applies to all tasks (e.g., design guidelines)
*/
loadSkillsPrompt() {
try {
const skillsPath = path.join(this.workingDir, 'Skills.md');
if (fs.existsSync(skillsPath)) {
this.skillsPrompt = fs.readFileSync(skillsPath, 'utf8').trim();
ui.info('Loaded skills from Skills.md');
}
Comment on lines +92 to +98
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadSkillsPrompt() logs "Loaded skills from Skills.md" even when the file is present but trims down to an empty string. Since chat() later checks if (this.skillsPrompt) (truthy) before prepending, an empty Skills.md results in a "loaded" log but no skills section in the prompt. Consider treating an all-whitespace file as absent (e.g., set skillsPrompt back to null and/or only log when the trimmed content is non-empty) to keep behavior consistent.

Copilot uses AI. Check for mistakes.
} catch (error) {
ui.warning(`Could not load Skills.md: ${error.message}`);
}
}

/**
* Load custom system prompt from Coderrr.md in project directory
* This is task-specific guidance that may change per task
*/
loadCustomPrompt() {
try {
const customPromptPath = path.join(this.workingDir, 'Coderrr.md');
if (fs.existsSync(customPromptPath)) {
this.customPrompt = fs.readFileSync(customPromptPath, 'utf8').trim();
ui.info('Loaded custom system prompt from Coderrr.md');
ui.info('Loaded task prompt from Coderrr.md');
}
} catch (error) {
ui.warning(`Could not load Coderrr.md: ${error.message}`);
Expand Down Expand Up @@ -124,6 +142,11 @@ class Agent {
*/
async chat(prompt, options = {}) {
try {
// Load skills prompt on first request if not already loaded
if (this.skillsPrompt === null) {
this.loadSkillsPrompt();
}

// Load custom prompt on first request if not already loaded
if (this.customPrompt === null) {
this.loadCustomPrompt();
Expand All @@ -144,14 +167,21 @@ class Agent {
}
}

// Enhance prompt with custom prompt and codebase context
// Build enhanced prompt with priority:
// 1. System Prompt (embedded in backend)
// 2. Skills.md (persistent skills)
// 3. Coderrr.md (task-specific guidance)
// 4. User prompt
let enhancedPrompt = prompt;

// Prepend custom prompt if available
// Prepend task-specific prompt (Coderrr.md) if available
if (this.customPrompt) {
enhancedPrompt = `${this.customPrompt}
enhancedPrompt = `[TASK GUIDANCE]\n${this.customPrompt}\n\n[USER REQUEST]\n${enhancedPrompt}`;
}

${prompt}`;
// Prepend skills prompt (Skills.md) if available - comes before task prompt
if (this.skillsPrompt) {
enhancedPrompt = `[SKILLS]\n${this.skillsPrompt}\n\n${enhancedPrompt}`;
}

if (this.codebaseContext) {
Expand Down
1 change: 1 addition & 0 deletions src/fileOps.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ui = require('./ui');
// Protected paths that should never be deleted by Coderrr
const PROTECTED_PATHS = [
'Coderrr.md',
'Skills.md',
'.coderrr'
];
Comment on lines 14 to 18
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isProtectedPath() checks protected filenames with a case-sensitive match (PROTECTED_PATHS.includes(basename)). On case-insensitive file systems (e.g., Windows/macOS default), a deletion request for skills.md can still resolve to Skills.md and bypass protection. Consider normalizing both basename/relativePath and PROTECTED_PATHS to a consistent case (e.g., lower-case) before comparing so the protection can't be bypassed by casing.

Copilot uses AI. Check for mistakes.

Expand Down
194 changes: 194 additions & 0 deletions tests/unit/skills.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
const fs = require('fs');
const path = require('path');
const Agent = require('../../src/agent');

// Mock dependencies
jest.mock('../../src/ui', () => ({
info: jest.fn(),
warning: jest.fn(),
success: jest.fn(),
error: jest.fn(),
spinner: jest.fn(() => ({ start: jest.fn(), stop: jest.fn() })),
section: jest.fn(),
space: jest.fn(),
confirm: jest.fn(),
displayFileOp: jest.fn(),
displayDiff: jest.fn()
}));

jest.mock('../../src/configManager', () => ({
initializeProjectStorage: jest.fn(),
loadProjectMemory: jest.fn(() => []),
saveProjectMemory: jest.fn(),
clearProjectMemory: jest.fn(),
getConfig: jest.fn()
}));

jest.mock('axios');

describe('Skills.md Loading', () => {
let tempDir;
const originalCwd = process.cwd();

beforeEach(() => {
jest.clearAllMocks();
// Create a temporary directory for testing
tempDir = path.join(originalCwd, 'test-temp-skills');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
});
Comment on lines +33 to +40
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test temp directory uses a fixed path (test-temp-skills) under the repo root. This can collide across parallel Jest runs and can leave artifacts in the working tree if a test crashes before cleanup. Prefer creating a unique temp dir via fs.mkdtempSync(path.join(os.tmpdir(), ...)) and clean it up with fs.rmSync(tempDir, { recursive: true, force: true }).

Copilot uses AI. Check for mistakes.

afterEach(() => {
// Clean up temporary directory
if (fs.existsSync(tempDir)) {
const files = fs.readdirSync(tempDir);
for (const file of files) {
fs.unlinkSync(path.join(tempDir, file));
}
fs.rmdirSync(tempDir);
}
});

describe('loadSkillsPrompt', () => {
it('should load Skills.md when it exists', () => {
const skillsContent = '# Frontend Skills\n- Use modern design patterns';
fs.writeFileSync(path.join(tempDir, 'Skills.md'), skillsContent);

const agent = new Agent({ workingDir: tempDir });
agent.loadSkillsPrompt();

expect(agent.skillsPrompt).toBe(skillsContent);
});

it('should not set skillsPrompt when Skills.md does not exist', () => {
const agent = new Agent({ workingDir: tempDir });
agent.loadSkillsPrompt();

expect(agent.skillsPrompt).toBeNull();
});

it('should handle empty Skills.md gracefully', () => {
fs.writeFileSync(path.join(tempDir, 'Skills.md'), ' ');

const agent = new Agent({ workingDir: tempDir });
agent.loadSkillsPrompt();

expect(agent.skillsPrompt).toBe('');
});
});

describe('loadCustomPrompt', () => {
it('should load Coderrr.md when it exists', () => {
const taskContent = '# Task: Build landing page';
fs.writeFileSync(path.join(tempDir, 'Coderrr.md'), taskContent);

const agent = new Agent({ workingDir: tempDir });
agent.loadCustomPrompt();

expect(agent.customPrompt).toBe(taskContent);
});

it('should not set customPrompt when Coderrr.md does not exist', () => {
const agent = new Agent({ workingDir: tempDir });
agent.loadCustomPrompt();

expect(agent.customPrompt).toBeNull();
});
});

describe('Prompt Priority Order', () => {
it('should construct prompt with Skills.md before Coderrr.md', () => {
const skillsContent = '# Skills\n- Be modern';
const taskContent = '# Task\n- Build a form';
fs.writeFileSync(path.join(tempDir, 'Skills.md'), skillsContent);
fs.writeFileSync(path.join(tempDir, 'Coderrr.md'), taskContent);

const agent = new Agent({ workingDir: tempDir, scanOnFirstRequest: false });
agent.loadSkillsPrompt();
agent.loadCustomPrompt();

// Simulate prompt construction (from chat method logic)
let enhancedPrompt = 'User request';

// Task guidance first (will be wrapped by skills)
if (agent.customPrompt) {
enhancedPrompt = `[TASK GUIDANCE]\n${agent.customPrompt}\n\n[USER REQUEST]\n${enhancedPrompt}`;
}

// Skills prepended (comes before everything else)
if (agent.skillsPrompt) {
enhancedPrompt = `[SKILLS]\n${agent.skillsPrompt}\n\n${enhancedPrompt}`;
}

// Verify priority order: Skills comes first
expect(enhancedPrompt.startsWith('[SKILLS]')).toBe(true);
expect(enhancedPrompt.indexOf('[SKILLS]')).toBeLessThan(enhancedPrompt.indexOf('[TASK GUIDANCE]'));
expect(enhancedPrompt.indexOf('[TASK GUIDANCE]')).toBeLessThan(enhancedPrompt.indexOf('[USER REQUEST]'));
Comment on lines +111 to +127
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prompt priority tests rebuild enhancedPrompt manually instead of exercising the production Agent.chat() logic. This duplicates implementation details and can keep passing even if chat() prompt construction changes. Consider calling agent.chat('User request', …) with scanOnFirstRequest: false and a mocked axios.post, then assert against the actual requestPayload.prompt passed to axios (order of [SKILLS], [TASK GUIDANCE], and [USER REQUEST]).

Copilot uses AI. Check for mistakes.
});

it('should work with only Skills.md (no Coderrr.md)', () => {
const skillsContent = '# Skills\n- Be creative';
fs.writeFileSync(path.join(tempDir, 'Skills.md'), skillsContent);

const agent = new Agent({ workingDir: tempDir, scanOnFirstRequest: false });
agent.loadSkillsPrompt();
agent.loadCustomPrompt();

let enhancedPrompt = 'User request';

if (agent.customPrompt) {
enhancedPrompt = `[TASK GUIDANCE]\n${agent.customPrompt}\n\n[USER REQUEST]\n${enhancedPrompt}`;
}

if (agent.skillsPrompt) {
enhancedPrompt = `[SKILLS]\n${agent.skillsPrompt}\n\n${enhancedPrompt}`;
}

expect(enhancedPrompt).toContain('[SKILLS]');
expect(enhancedPrompt).not.toContain('[TASK GUIDANCE]');
expect(enhancedPrompt).toContain('User request');
});

it('should work with only Coderrr.md (no Skills.md)', () => {
const taskContent = '# Task\n- Do something';
fs.writeFileSync(path.join(tempDir, 'Coderrr.md'), taskContent);

const agent = new Agent({ workingDir: tempDir, scanOnFirstRequest: false });
agent.loadSkillsPrompt();
agent.loadCustomPrompt();

let enhancedPrompt = 'User request';

if (agent.customPrompt) {
enhancedPrompt = `[TASK GUIDANCE]\n${agent.customPrompt}\n\n[USER REQUEST]\n${enhancedPrompt}`;
}

if (agent.skillsPrompt) {
enhancedPrompt = `[SKILLS]\n${agent.skillsPrompt}\n\n${enhancedPrompt}`;
}

expect(enhancedPrompt).not.toContain('[SKILLS]');
expect(enhancedPrompt).toContain('[TASK GUIDANCE]');
expect(enhancedPrompt).toContain('User request');
});

it('should work with neither Skills.md nor Coderrr.md', () => {
const agent = new Agent({ workingDir: tempDir, scanOnFirstRequest: false });
agent.loadSkillsPrompt();
agent.loadCustomPrompt();

let enhancedPrompt = 'User request';

if (agent.customPrompt) {
enhancedPrompt = `[TASK GUIDANCE]\n${agent.customPrompt}\n\n[USER REQUEST]\n${enhancedPrompt}`;
}

if (agent.skillsPrompt) {
enhancedPrompt = `[SKILLS]\n${agent.skillsPrompt}\n\n${enhancedPrompt}`;
}

expect(enhancedPrompt).toBe('User request');
});
});
});
Loading