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
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 };
174 changes: 174 additions & 0 deletions src/plugins/evolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// SmallCode — Evolver (create-mode mechanics)
// Deterministic mechanics behind the /evolve command: proposal building,
// validation, name-collision checking, quarantined draft writing, audit
// logging, and structural enforcement of the 1-create-per-run cap.
//
// The fuzzy judgment (is this friction worth a skill?) happens in the
// command handler via an LLM call. Everything here is pure mechanics so it
// can be unit-tested without a model.
//
// Safety rules (mirrors the create-mode evolver pattern):
// - Drafts only: writes go to .smallcode/skills/drafts/, never live dirs
// - Never deletes, never commits
// - validateProposal must pass before any write
// - EvolverRun raises on the 2nd create in a single run

const fs = require('fs');
const path = require('path');
const { appendEntry } = require('./audit_log');

const MAX_CREATES_PER_RUN = 1;
const NAME_RE = /^[A-Za-z0-9_-]+$/;
const VALID_TRIGGERS = new Set(['manual', 'auto', 'match']);

class ProposalCapExceededError extends Error {}

// ── Builders ──────────────────────────────────────────────────────────────

function buildSkillProposal(name, description, body, options = {}) {
return {
kind: 'create',
artefact: 'skill',
name,
description,
body,
trigger: options.trigger || 'manual',
keywords: Array.isArray(options.keywords) ? options.keywords : [],
rationale: options.rationale || '',
};
}

// ── Validation ────────────────────────────────────────────────────────────

function validateProposal(proposal) {
const errors = [];
if (!proposal || typeof proposal !== 'object') return ['proposal must be an object'];

if (proposal.artefact !== 'skill') {
errors.push(`artefact must be "skill", got ${JSON.stringify(proposal.artefact)}`);
}
if (typeof proposal.name !== 'string' || !NAME_RE.test(proposal.name)) {
errors.push('name must be a non-empty alphanumeric/-_ string');
}
if (typeof proposal.description !== 'string' || !proposal.description.trim()) {
errors.push('description must be a non-empty string');
} else if (/[\r\n]/.test(proposal.description)) {
errors.push('description must not contain newlines (frontmatter-injection risk)');
}
if (typeof proposal.body !== 'string' || !proposal.body.trim()) {
errors.push('body must be a non-empty string');
}
if (!VALID_TRIGGERS.has(proposal.trigger)) {
errors.push(`trigger must be one of manual|auto|match, got ${JSON.stringify(proposal.trigger)}`);
}
if (proposal.trigger === 'match' && (!Array.isArray(proposal.keywords) || proposal.keywords.length === 0)) {
errors.push('trigger "match" requires a non-empty keywords list');
}
return errors;
}

// ── Name-collision check ──────────────────────────────────────────────────

// Look for an existing skill with this name across the standard skill dirs
// (live and drafts). Returns the first matching path or null.
function checkNameCollision(name, projectDir) {
const os = require('os');
const roots = [
path.join(projectDir, '.smallcode', 'skills'),
path.join(os.homedir(), '.smallcode', 'skills'),
path.join(os.homedir(), '.config', 'smallcode', 'skills'),
];
for (const root of roots) {
for (const candidate of [
path.join(root, `${name}.md`),
path.join(root, name, 'SKILL.md'),
path.join(root, 'drafts', `${name}.md`),
]) {
if (fs.existsSync(candidate)) return candidate;
}
}
return null;
}

// ── Draft writer ──────────────────────────────────────────────────────────

function _skillMd(proposal) {
const fm = [
'---',
`name: ${proposal.name}`,
`description: ${proposal.description}`,
`trigger: ${proposal.trigger}`,
proposal.keywords.length ? `keywords: [${proposal.keywords.join(', ')}]` : null,
'---',
].filter(Boolean).join('\n');
let body = proposal.body.trim() + '\n';
if (proposal.rationale) {
body += `\n<!-- Auto-drafted by /evolve. Rationale: ${proposal.rationale.replace(/-->/g, '')} -->\n`;
}
return `${fm}\n${body}`;
}

function writeDraft(proposal, projectDir) {
const errors = validateProposal(proposal);
if (errors.length) throw new Error(`invalid proposal: ${errors.join('; ')}`);

const draftsDir = path.resolve(projectDir, '.smallcode', 'skills', 'drafts');
const target = path.resolve(draftsDir, `${proposal.name}.md`);
// Path containment — name is already validated, but defend anyway
if (!target.startsWith(draftsDir + path.sep)) {
throw new Error(`draft path escapes drafts dir: ${target}`);
}
if (!fs.existsSync(draftsDir)) fs.mkdirSync(draftsDir, { recursive: true });
const tmpPath = target + `.tmp.${process.pid}.${Date.now()}`;
fs.writeFileSync(tmpPath, _skillMd(proposal), 'utf-8');
fs.renameSync(tmpPath, target);
return target;
}

// ── Audit log ─────────────────────────────────────────────────────────────

function logCreateEvent(auditPath, proposal, rationale, sourceTraceIds) {
appendEntry(auditPath, {
ts: new Date().toISOString(),
kind: 'create',
artefact: proposal.artefact,
name: proposal.name,
rationale: rationale || proposal.rationale || '',
source_traces: Array.isArray(sourceTraceIds) ? sourceTraceIds : [],
});
}

// ── Per-run cap (structural) ──────────────────────────────────────────────

// Stateful tracker enforcing the create cap by construction. Use this, not
// writeDraft directly, when running an evolution pass.
class EvolverRun {
constructor(maxCreates = MAX_CREATES_PER_RUN) {
this.maxCreates = maxCreates;
this.createsSoFar = 0;
this.written = [];
}

writeDraft(proposal, projectDir) {
if (proposal && proposal.kind === 'create' && this.createsSoFar >= this.maxCreates) {
throw new ProposalCapExceededError(
`already wrote ${this.createsSoFar} create(s); cap is ${this.maxCreates}`
);
}
const target = writeDraft(proposal, projectDir);
if (proposal.kind === 'create') this.createsSoFar++;
this.written.push(target);
return target;
}
}

module.exports = {
buildSkillProposal,
validateProposal,
checkNameCollision,
writeDraft,
logCreateEvent,
EvolverRun,
ProposalCapExceededError,
MAX_CREATES_PER_RUN,
};
Loading