Skip to content
Closed
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
156 changes: 156 additions & 0 deletions bin/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ module.exports = function createCommandHandler(config, conversationHistory, impr
console.log(` ${chalk.cyan('/budget')} ${chalk.gray('Show context window budget')}`);
console.log(` ${chalk.cyan('/mcp')} ${chalk.gray('Show connected MCP servers')}`);
console.log(` ${chalk.cyan('/skill')} ${chalk.gray('Manage reusable skills')}`);
console.log(` ${chalk.cyan('/evolve')} ${chalk.gray('Propose a new skill from session friction (list|promote|log)')}`);
console.log(` ${chalk.cyan('/plugin')} ${chalk.gray('List installed plugins')}`);
console.log(` ${chalk.cyan('/provider')} ${chalk.gray('Configure LLM provider (interactive wizard)')}`);
console.log(` ${chalk.cyan('/sessions')} ${chalk.gray('List/resume saved sessions')}`);
Expand All @@ -863,6 +864,161 @@ module.exports = function createCommandHandler(config, conversationHistory, impr
rl.prompt();
return;

case '/evolve': {
const { SkillManager } = require('../src/plugins/skills');
const sm = new SkillManager(process.cwd());
const sub = (parts[1] || '').trim();

if (sub === 'list') {
const drafts = sm.listDrafts();
if (drafts.length === 0) {
console.log(chalk.gray(' No skill drafts. Run /evolve to analyze recent sessions.'));
} else {
console.log(chalk.bold(` Drafts (${drafts.length}) — promote with /evolve promote <name>:`));
for (const d of drafts) console.log(` ${chalk.cyan(d)}`);
}
console.log('');
rl.prompt();
return;
}

if (sub === 'promote') {
const name = (parts[2] || '').trim();
if (!name) { console.log(chalk.gray(' Usage: /evolve promote <name>')); }
else {
const target = sm.promoteDraft(name);
if (target) console.log(` ${chalk.green('✓')} Promoted to ${chalk.cyan(target)} — active next session.`);
else console.log(chalk.red(` Draft "${name}" not found (or a live skill with that name exists).`));
}
console.log('');
rl.prompt();
return;
}

if (sub === 'log') {
const { readEntries } = require('../src/plugins/audit_log');
const entries = readEntries(path.join(process.cwd(), '.smallcode', 'evolver-audit.jsonl'), 10);
if (entries.length === 0) console.log(chalk.gray(' No evolution events logged yet.'));
for (const e of entries) {
console.log(` ${chalk.gray(e.ts)} ${chalk.cyan(e.name)} ${chalk.gray(e.rationale.slice(0, 60))}`);
}
console.log('');
rl.prompt();
return;
}

// No sub-command: run an evolution pass
const { TraceRecorder } = require('./trace_recorder');
const { extractFrictionSignals, formatReportForPrompt } = require('../src/plugins/friction_analyzer');
const evolver = require('../src/plugins/evolver');

const tr = new TraceRecorder(process.cwd());
const traceList = tr.list().slice(0, 20);
if (traceList.length < 3) {
console.log(chalk.gray(` Only ${traceList.length} trace(s) recorded — need at least 3 sessions of data.`));
console.log('');
rl.prompt();
return;
}
const traces = traceList.map(t => tr.load(t.id)).filter(Boolean);

const skillKeywords = sm.list().flatMap(s => s.keywords || []);
const report = extractFrictionSignals(traces, { skillKeywords });
const signalCount = report.repeated_patterns.length + report.tool_retry_loops.length;
if (signalCount === 0) {
console.log(chalk.gray(` No friction patterns in last ${traces.length} traces. Nothing to evolve.`));
console.log('');
rl.prompt();
return;
}

console.log(chalk.bold(` Friction signals (${signalCount}):`));
console.log(chalk.gray(formatReportForPrompt(report).split('\n').map(l => ' ' + l).join('\n')));

// LLM judgment — route to the strong tier when configured
const { getModelTarget, buildAuthHeaders, withModelTarget } = require('./config');
const target = getModelTarget(config, 'strong');
process.stdout.write(chalk.gray(` Asking ${target.model} for a proposal... `));

const sysPrompt = 'You design reusable skills for a coding agent. A skill is a short markdown instruction injected when relevant. Given friction signals from recent sessions, propose ONE skill addressing the most impactful pattern. Respond with ONLY a JSON object: {"name": "kebab-case-name", "description": "one line", "trigger": "match", "keywords": ["k1","k2"], "body": "markdown instructions for the agent", "rationale": "why this helps"}';
let proposalRaw = null;
try {
const resp = await fetch(`${target.baseUrl}/chat/completions`, {
method: 'POST',
headers: buildAuthHeaders(withModelTarget(config, target)),
body: JSON.stringify({
model: target.model,
messages: [
{ role: 'system', content: sysPrompt },
{ role: 'user', content: `Friction signals:\n${formatReportForPrompt(report)}` },
],
temperature: 0.2,
max_tokens: 1024,
}),
});
if (resp.ok) {
const data = await resp.json();
proposalRaw = data?.choices?.[0]?.message?.content || null;
} else {
console.log(chalk.red(`HTTP ${resp.status}`));
}
} catch (e) {
console.log(chalk.red(e.message));
}
if (!proposalRaw) { console.log(''); rl.prompt(); return; }

// Forgiving parse: strict JSON → fenced JSON → abort with raw output
let parsed = null;
try { parsed = JSON.parse(proposalRaw); } catch {
const m = proposalRaw.match(/\{[\s\S]*\}/);
if (m) { try { parsed = JSON.parse(m[0]); } catch {} }
}
if (!parsed) {
console.log(chalk.yellow('could not parse'));
console.log(chalk.gray(' Raw model output (nothing written):'));
console.log(chalk.gray(' ' + proposalRaw.slice(0, 500).split('\n').join('\n ')));
console.log('');
rl.prompt();
return;
}
console.log(chalk.green('ok'));

const proposal = evolver.buildSkillProposal(
String(parsed.name || ''), String(parsed.description || ''), String(parsed.body || ''),
{ trigger: parsed.trigger, keywords: parsed.keywords, rationale: String(parsed.rationale || '') }
);
const errors = evolver.validateProposal(proposal);
if (errors.length) {
console.log(chalk.red(` Proposal rejected: ${errors.join('; ')}`));
console.log('');
rl.prompt();
return;
}
const collision = evolver.checkNameCollision(proposal.name, process.cwd());
if (collision) {
console.log(chalk.red(` Name collision with ${collision} — nothing written.`));
console.log('');
rl.prompt();
return;
}

const run = new evolver.EvolverRun();
const draftPath = run.writeDraft(proposal, process.cwd());
evolver.logCreateEvent(
path.join(process.cwd(), '.smallcode', 'evolver-audit.jsonl'),
proposal, proposal.rationale,
report.repeated_patterns.flatMap(p => p.traceIds).concat(report.tool_retry_loops.flatMap(l => l.traceIds))
);

console.log('');
console.log(` ${chalk.green('✓')} Draft: ${chalk.cyan(draftPath)}`);
console.log(chalk.gray(` "${proposal.description}"`));
console.log(chalk.gray(` Review the file, then: /evolve promote ${proposal.name}`));
console.log('');
rl.prompt();
return;
}

case '/provider': {
const sub = (parts[1] || '').trim();
if (sub === 'status' || sub === '--status' || sub === '-s') {
Expand Down
18 changes: 18 additions & 0 deletions bin/executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,24 @@ async function executeTool(name, args, ctx) {
return { result: '' };
}

case 'use_skill': {
const skillManager = ctx.skillManager || null;
if (!skillManager) return { error: 'use_skill: skill system not available' };
const skillName = String(args.name || '').trim();
if (!skillName) return { error: 'use_skill: name is required' };
const skill = skillManager.get(skillName);
if (!skill) {
const validNames = skillManager.getIndex().map(e => e.name).slice(0, 10);
return { error: `use_skill: skill "${skillName}" not found. Valid names: ${validNames.join(', ')}` };
}
const { formatSkillResult } = require('../src/plugins/skill_index_formatter');
const index = skillManager.getIndex();
const relatedEntries = (skill.related || [])
.map(r => index.find(e => e.name === r))
.filter(Boolean);
return { result: formatSkillResult(skill, relatedEntries) };
}

case 'bone_compile': {
const safe = safeResolvePath(args.path, cwd);
if (!safe.ok) return { error: `bone_compile rejected: ${safe.reason}` };
Expand Down
25 changes: 17 additions & 8 deletions bin/smallcode.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ async function executeTool(name, args) {
flags,
config,
tui,
skillManager,
});

try { if (dedup) dedup.record(name, args, result); } catch {}
Expand Down Expand Up @@ -2086,21 +2087,29 @@ function getMemoryContext(messages) {
}
}

// Auto-load relevant skills based on the user's message
// Auto-load relevant skills based on the user's message.
// Fix #18: Cap skill injection to ~1000 tokens (4000 chars). Multiple matching
// skills can each be a full .md file, quickly blowing up the system prompt.
//
// Lazy-skills: always inject the compact index (one line per skill, ~8 tokens each)
// so the model can call use_skill to pull any body on demand. Auto-matched skill
// bodies are appended after the index, subject to the 4000-char aggregate cap.
function getSkillContext(messages) {
if (!skillManager) return '';
try {
const { formatSkillIndex } = require('../src/plugins/skill_index_formatter');
const index = skillManager.getIndex();
const indexStr = formatSkillIndex(index);

const lastUser = [...messages].reverse().find(m => m.role === 'user');
if (!lastUser) return '';
const skills = skillManager.getAutoSkills(lastUser.content);
if (skills.length === 0) return '';
const formatted = skillManager.formatForPrompt(skills);
const autoSkills = lastUser ? skillManager.getAutoSkills(lastUser.content) : [];
const autoFormatted = skillManager.formatForPrompt(autoSkills);

const combined = indexStr + (autoFormatted ? '\n' + autoFormatted : '');
// Hard cap: truncate if too long
return formatted.length > 4000
? formatted.slice(0, 4000) + '\n... (skills truncated to fit context)'
: formatted;
return combined.length > 4000
? combined.slice(0, 4000) + '\n... (skills truncated to fit context)'
: combined;
} catch {
return '';
}
Expand Down
1 change: 1 addition & 0 deletions bin/tools.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 11 additions & 8 deletions src/compiled/tool_router.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,25 +228,28 @@ function classifyToolCategory(message) {
* @returns {string[]} tool names to include in the prompt
*/
function getToolsForCategory(category) {
// use_skill rides along in every tool-bearing category — the skill index
// is injected on every turn, so the model must always be able to pull a
// skill body regardless of how the task was classified (~80 token cost).
switch (category) {
case 'code_intel':
return ['graph_search', 'explain_symbol', 'read_file', 'find_files', 'search', 'hybrid_search'];
return ['graph_search', 'explain_symbol', 'read_file', 'find_files', 'search', 'hybrid_search', 'use_skill'];
case 'read':
return ['read_file', 'list_projects', 'graph_search', 'find_files', 'find_and_read'];
return ['read_file', 'list_projects', 'graph_search', 'find_files', 'find_and_read', 'use_skill'];
case 'write':
return ['read_file', 'write_file', 'patch', 'bash', 'read_and_patch', 'create_and_run'];
return ['read_file', 'write_file', 'patch', 'bash', 'read_and_patch', 'create_and_run', 'use_skill'];
case 'search':
return ['search', 'find_files', 'graph_search', 'read_file', 'explain_symbol', 'search_and_read', 'hybrid_search'];
return ['search', 'find_files', 'graph_search', 'read_file', 'explain_symbol', 'search_and_read', 'hybrid_search', 'use_skill'];
case 'run':
return ['bash', 'run', 'read_file'];
return ['bash', 'run', 'read_file', 'use_skill'];
case 'plan':
return ['read_file', 'write_file', 'patch', 'bash', 'search', 'find_files', 'graph_search', 'memory_load', 'memory_remember', 'bone_compile', 'bone_check', 'read_and_patch', 'create_and_run', 'find_and_read', 'search_and_read'];
return ['read_file', 'write_file', 'patch', 'bash', 'search', 'find_files', 'graph_search', 'memory_load', 'memory_remember', 'bone_compile', 'bone_check', 'read_and_patch', 'create_and_run', 'find_and_read', 'search_and_read', 'use_skill'];
case 'web':
return ['web_search', 'web_fetch', 'read_file'];
return ['web_search', 'web_fetch', 'read_file', 'use_skill'];
case 'respond':
return []; // No tools needed for pure responses
default:
return ['read_file', 'write_file', 'patch', 'bash', 'search'];
return ['read_file', 'write_file', 'patch', 'bash', 'search', 'use_skill'];
}
}

Expand Down
33 changes: 33 additions & 0 deletions src/plugins/audit_log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SmallCode — Evolution Audit Log
// Thin JSONL appender/reader for evolver create events. One JSON object per
// line; append-only. Writes are atomic (tmp + rename) so a crash mid-write
// never corrupts existing history.

const fs = require('fs');
const path = require('path');

function appendEntry(filePath, entry) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const line = JSON.stringify(entry) + '\n';
// Read-modify-write atomically: copy existing content + new line to a tmp
// file, then rename over the original.
let existing = '';
try { existing = fs.readFileSync(filePath, 'utf-8'); } catch {}
const tmpPath = filePath + `.tmp.${process.pid}.${Date.now()}`;
fs.writeFileSync(tmpPath, existing + line, 'utf-8');
fs.renameSync(tmpPath, filePath);
}

function readEntries(filePath, limit = 100) {
let content = '';
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; }
const entries = [];
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try { entries.push(JSON.parse(line)); } catch {}
}
return entries.slice(-limit);
}

module.exports = { appendEntry, readEntries };
Loading