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
193 changes: 191 additions & 2 deletions bin/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,42 @@ module.exports = function createCommandHandler(config, conversationHistory, impr
} catch (e) {
console.log(chalk.gray(` Error: ${e.message}`));
}
} else if (sub === 'hygiene') {
try {
const { runHygiene } = require('../src/memory/hygiene');
const result = runHygiene(memoryStore);
console.log(chalk.green(` ✓ Hygiene complete: ${result.archived} archived, ${result.deleted} deleted`));
// Also write MEMORY.md index
const { renderMemoryIndex } = require('../src/memory/hygiene');
const md = renderMemoryIndex(memoryStore);
const fs = require('fs');
const path = require('path');
const outDir = path.join(process.cwd(), '.smallcode');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'MEMORY.md'), md);
console.log(chalk.gray(` Wrote .smallcode/MEMORY.md (${memoryStore.all().length} entries)`));
} catch (e) {
console.log(chalk.gray(` Hygiene error: ${e.message}`));
}
} else if (sub === 'index') {
try {
const { renderMemoryIndex } = require('../src/memory/hygiene');
const md = renderMemoryIndex(memoryStore);
const fs = require('fs');
const path = require('path');
const outDir = path.join(process.cwd(), '.smallcode');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'MEMORY.md'), md);
console.log(chalk.green(` ✓ Wrote .smallcode/MEMORY.md`));
console.log(md.split('\n').slice(0, 10).map(l => ' ' + l).join('\n'));
} catch (e) {
console.log(chalk.gray(` Index error: ${e.message}`));
}
} else {
console.log(chalk.gray(' /memory List stored memory'));
console.log(chalk.gray(' /memory clear Clear all memory'));
console.log(chalk.gray(' /memory List stored memory'));
console.log(chalk.gray(' /memory clear Clear all memory'));
console.log(chalk.gray(' /memory hygiene Sweep tiers, prune stale entries, write MEMORY.md'));
console.log(chalk.gray(' /memory index Write .smallcode/MEMORY.md without sweeping'));
}
console.log('');
rl.prompt();
Expand Down Expand Up @@ -851,6 +884,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 +897,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
31 changes: 31 additions & 0 deletions bin/executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,19 @@ async function executeTool(name, args, ctx) {
const objects = Array.isArray(raw) ? raw : (raw?.objects || []);
const tokens_used = Array.isArray(raw) ? objects.length * 50 : (raw?.tokens_used || 0);
if (objects.length === 0) return { result: 'No relevant memory found.' };
// Touch last_used_at so hygiene tier sweeps see real usage — an
// actively-retrieved entry must not age out. Never breaks retrieval.
for (const o of objects) {
try {
const now = new Date().toISOString();
if (typeof memoryStore.update === 'function') {
memoryStore.update(o.id, { last_used_at: now });
} else {
o.last_used_at = now;
if (typeof memoryStore.save === 'function') memoryStore.save();
}
} catch {}
}
const formatted = objects.map(o => `[${o.type}] ${o.title}: ${o.content}`).join('\n\n');
return { result: `Loaded ${objects.length} memories (${tokens_used} tokens):\n\n${formatted}` };
}
Expand Down Expand Up @@ -840,6 +853,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
5 changes: 4 additions & 1 deletion bin/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const INDEX_FILE = '.smallcode/memory/index.json';
// ─── Memory Object ───────────────────────────────────────────────────────────

class MemoryObject {
constructor({ id, type, title, content, tags, relations, createdAt, updatedAt, source }) {
constructor({ id, type, title, content, tags, relations, createdAt, updatedAt, source, tier, last_used_at }) {
this.id = id || crypto.randomUUID().slice(0, 8);
this.type = type; // decision | workflow | gotcha | convention | context | source
this.title = title;
Expand All @@ -30,13 +30,16 @@ class MemoryObject {
this.createdAt = createdAt || new Date().toISOString();
this.updatedAt = updatedAt || new Date().toISOString();
this.source = source || null; // { file, line, commit }
this.tier = tier || 'hot'; // hot | archive
this.last_used_at = last_used_at || this.createdAt;
}

toJSON() {
return {
id: this.id, type: this.type, title: this.title, content: this.content,
tags: this.tags, relations: this.relations,
createdAt: this.createdAt, updatedAt: this.updatedAt, source: this.source,
tier: this.tier, last_used_at: this.last_used_at,
};
}
}
Expand Down
28 changes: 20 additions & 8 deletions bin/smallcode.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ async function runTUI(config) {
onCommand: async (cmd) => {
if (cmd === '/quit' || cmd === '/q' || cmd === '/exit') {
if (sessionStore) sessionStore.save(conversationHistory, { tokens: tokenTracker ? tokenTracker.stats() : undefined });
try { if (memoryStore) { const { runHygiene } = require('../src/memory/hygiene'); runHygiene(memoryStore); } } catch {}
screen.leave();
killMCP()
process.exit(0);
Expand Down Expand Up @@ -318,6 +319,7 @@ async function runTUI(config) {
if (sessionStore) {
sessionStore.save(conversationHistory, { tokens: tokenTracker ? tokenTracker.stats() : undefined });
}
try { if (memoryStore) { const { runHygiene } = require('../src/memory/hygiene'); runHygiene(memoryStore); } } catch {}
killMCP()
process.exit(0);
},
Expand Down Expand Up @@ -444,6 +446,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 +2089,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 Expand Up @@ -2546,6 +2557,7 @@ async function chatCompletion(config, messages) {
});
sessionStore.autoTitle(conversationHistory);
}
try { if (memoryStore) { const { runHygiene } = require('../src/memory/hygiene'); runHygiene(memoryStore); } } catch {}

return data;
} catch (err) {
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.

Loading