From c284b2f7e97974a8dadb0b83cdcc3c0aa6b87ce9 Mon Sep 17 00:00:00 2001 From: shuff57 <62350898+shuff57@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:33:33 -0700 Subject: [PATCH 1/5] fix(skills): discover nested and frontmatter-less skills Skills following the Claude Code layout (//SKILL.md) or written as plain .md without YAML frontmatter were silently skipped in the standard skill dirs (.smallcode/skills, ~/.smallcode/skills, ~/.config/smallcode/skills). Both shapes now load; README-style files (README/CHANGELOG/LICENSE/CONTRIBUTING) are filtered by name. Fixes #81 Constraint: no warning channel exists in SkillManager, so silent skips had no user-visible signal Rejected: warn-on-skip only | users following Claude Code conventions expect these layouts to work Confidence: high Scope-risk: narrow Not-tested: fullscreen TUI /skill list rendering (logic shared with classic mode) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/plugins/skills.js | 74 ++++++++++++++++++++++++++----------------- test/skills.test.js | 36 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src/plugins/skills.js b/src/plugins/skills.js index 60c06bb1..d88354c7 100644 --- a/src/plugins/skills.js +++ b/src/plugins/skills.js @@ -16,6 +16,10 @@ // `.agents/skills` or `.claude/skills` typically have no frontmatter — they // are treated as `manual`-trigger skills named after their parent directory. // +// The standard skill dirs also accept the nested `/SKILL.md` layout and +// flat `.md` files without frontmatter (named after the file) — both were +// previously skipped silently (closes #81). README-style files are ignored. +// // Frontmatter accepts both LF and CRLF line endings (closes #52). const fs = require('fs'); @@ -24,6 +28,8 @@ const os = require('os'); const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/; const KV_RE = /^(\w+)\s*:\s*(.+?)\s*$/; +// Docs that live alongside skills but aren't skills themselves +const NON_SKILL_MD = /^(readme|changelog|license|contributing)\.md$/i; class SkillManager { constructor(projectDir) { @@ -71,14 +77,20 @@ class SkillManager { if (!dir || !fs.existsSync(dir)) return; let entries; try { - entries = fs.readdirSync(dir); + entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - if (!entry.endsWith('.md')) continue; - const full = path.join(dir, entry); - this._ingestFile(full, entry, dir); + if (entry.isDirectory()) { + // //SKILL.md inside a standard skill dir — users following + // the Claude Code layout expect this to work (closes #81) + this._loadSkillFolder(path.join(dir, entry.name), entry.name); + continue; + } + if (!entry.name.endsWith('.md') || NON_SKILL_MD.test(entry.name)) continue; + const full = path.join(dir, entry.name); + this._ingestFile(full, entry.name, dir, entry.name.replace(/\.md$/i, ''), 'flat'); } } @@ -92,38 +104,41 @@ class SkillManager { } for (const d of dirs) { if (!d.isDirectory()) continue; - const skillDir = path.join(root, d.name); - // Look for SKILL.md, skill.md, or any .md file inside the folder. - let skillFile = null; - const candidates = ['SKILL.md', 'skill.md', 'Skill.md']; - for (const c of candidates) { - const p = path.join(skillDir, c); - if (fs.existsSync(p)) { skillFile = p; break; } - } - if (!skillFile) { - // Fall back to first .md in the folder - try { - const md = fs.readdirSync(skillDir).find(f => f.endsWith('.md')); - if (md) skillFile = path.join(skillDir, md); - } catch {} - } - if (!skillFile) continue; - this._ingestFile(skillFile, path.basename(skillFile), skillDir, d.name); + this._loadSkillFolder(path.join(root, d.name), d.name); + } + } + + _loadSkillFolder(skillDir, name) { + // Look for SKILL.md, skill.md, or any .md file inside the folder. + let skillFile = null; + const candidates = ['SKILL.md', 'skill.md', 'Skill.md']; + for (const c of candidates) { + const p = path.join(skillDir, c); + if (fs.existsSync(p)) { skillFile = p; break; } + } + if (!skillFile) { + // Fall back to first .md in the folder + try { + const md = fs.readdirSync(skillDir).find(f => f.endsWith('.md')); + if (md) skillFile = path.join(skillDir, md); + } catch {} } + if (!skillFile) return; + this._ingestFile(skillFile, path.basename(skillFile), skillDir, name, 'nested'); } - _ingestFile(filePath, filename, dir, defaultName) { + _ingestFile(filePath, filename, dir, defaultName, origin) { let content; try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return; } - const skill = this._parse(content, filename, dir, defaultName); + const skill = this._parse(content, filename, dir, defaultName, origin); if (skill) this.skills.set(skill.name, skill); } - _parse(content, filename, dir, defaultName) { + _parse(content, filename, dir, defaultName, origin) { // Parse YAML frontmatter (CRLF + LF tolerant — closes #52) const fmMatch = content.match(FM_RE); let frontmatter = ''; @@ -133,9 +148,10 @@ class SkillManager { frontmatter = fmMatch[1]; body = fmMatch[2]; } else if (!defaultName) { - // Flat-layout files without frontmatter aren't skills (could be a - // README). Nested-layout (.agents/skills//SKILL.md) files are - // accepted as plain-body skills using the parent directory name. + // Files without frontmatter and no derivable name aren't skills. + // Flat + nested loaders always pass a defaultName, so frontmatter-less + // files load as manual skills (closes #81); README-style files are + // filtered by name in _loadFlat. return null; } @@ -155,11 +171,11 @@ class SkillManager { return { name: meta.name || defaultName || filename.replace(/\.md$/i, ''), - trigger: meta.trigger || (defaultName ? 'manual' : 'manual'), + trigger: meta.trigger || 'manual', keywords: Array.isArray(meta.keywords) ? meta.keywords : [], content: body.trim(), path: path.join(dir, filename), - origin: defaultName ? 'nested' : 'flat', + origin: origin || (defaultName ? 'nested' : 'flat'), }; } diff --git a/test/skills.test.js b/test/skills.test.js index 087d756c..75ecf92f 100644 --- a/test/skills.test.js +++ b/test/skills.test.js @@ -98,6 +98,42 @@ test('list() reports nested skills with origin marker', () => { assert.equal(nested.origin, 'nested'); }); +test('issue #81: nested /SKILL.md inside .smallcode/skills is detected', () => { + const dir = freshProject(); + const skillFile = path.join(dir, '.smallcode', 'skills', 'my-skill', 'SKILL.md'); + write(skillFile, '# my skill\n\nDo nested things.'); + + const sm = new SkillManager(dir); + const got = sm.get('my-skill'); + assert.ok(got, 'nested skill inside .smallcode/skills should load'); + assert.equal(got.origin, 'nested'); + assert.match(got.content, /Do nested things\./); +}); + +test('issue #81: flat .md without frontmatter loads as manual skill', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'plain.md'), + '# Plain Skill\n\nNo frontmatter here.'); + + const sm = new SkillManager(dir); + const got = sm.get('plain'); + assert.ok(got, 'frontmatter-less flat skill should load'); + assert.equal(got.trigger, 'manual'); + assert.equal(got.origin, 'flat'); + assert.match(got.content, /No frontmatter here\./); +}); + +test('issue #81: README-style files in skill dirs are not skills', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'README.md'), '# About these skills'); + write(path.join(dir, '.smallcode', 'skills', 'real.md'), + '---\nname: real\ntrigger: manual\n---\nreal body'); + + const sm = new SkillManager(dir); + assert.equal(sm.get('README'), null); + assert.ok(sm.get('real')); +}); + test('add() persists a new skill and round-trips through .smallcode/skills', () => { const dir = freshProject(); const sm = new SkillManager(dir); From 086fa4a7456804dc301e279436e65a47518fc69d Mon Sep 17 00:00:00 2001 From: shuff57 <62350898+shuff57@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:30:24 -0700 Subject: [PATCH 2/5] feat(evolver): /evolve proposes skills from session friction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create-mode evolver: deterministic friction extraction from saved traces (repeated near-duplicate prompts, consecutive tool-retry loops), LLM judgment routed to the strong tier, and ONE quarantined skill draft per run written to .smallcode/skills/drafts/. Drafts never auto-load; /evolve promote moves them live. Validation gates every write (name format, no frontmatter injection, trigger rules); name collisions across live+draft+global dirs abort; every create appends to .smallcode/evolver-audit.jsonl. The per-run cap is structural — EvolverRun raises on a second create. Constraint: small models produce noisy judgments, so all fuzzy output passes validate-or-abort before any write Rejected: plugin delivery | needs TraceRecorder + SkillManager internals unreachable from plugin dirs under binary installs Confidence: high Scope-risk: narrow Directive: keep mechanics LLM-free — judgment stays in the command handler so mechanics remain unit-testable Not-tested: strong-tier routing with a separately configured SMALLCODE_MODEL_STRONG endpoint Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/commands.js | 156 ++++++++++++++++++++ src/plugins/audit_log.js | 33 +++++ src/plugins/evolver.js | 174 ++++++++++++++++++++++ src/plugins/friction_analyzer.js | 123 ++++++++++++++++ src/plugins/skills.js | 30 ++++ src/tui/fullscreen.js | 1 + test/evolver.test.js | 240 +++++++++++++++++++++++++++++++ 7 files changed, 757 insertions(+) create mode 100644 src/plugins/audit_log.js create mode 100644 src/plugins/evolver.js create mode 100644 src/plugins/friction_analyzer.js create mode 100644 test/evolver.test.js diff --git a/bin/commands.js b/bin/commands.js index 9ba5ebc8..e1a7e11c 100644 --- a/bin/commands.js +++ b/bin/commands.js @@ -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')}`); @@ -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 :`)); + 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 ')); } + 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') { diff --git a/src/plugins/audit_log.js b/src/plugins/audit_log.js new file mode 100644 index 00000000..12b2591d --- /dev/null +++ b/src/plugins/audit_log.js @@ -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 }; diff --git a/src/plugins/evolver.js b/src/plugins/evolver.js new file mode 100644 index 00000000..139a3123 --- /dev/null +++ b/src/plugins/evolver.js @@ -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/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, +}; diff --git a/src/plugins/friction_analyzer.js b/src/plugins/friction_analyzer.js new file mode 100644 index 00000000..2ba25bed --- /dev/null +++ b/src/plugins/friction_analyzer.js @@ -0,0 +1,123 @@ +// SmallCode — Friction Analyzer +// Deterministic friction-signal extraction from saved traces. No LLM calls — +// this produces the evidence the /evolve command hands to the model for +// judgment. +// +// Signals: +// - repeated_patterns: near-duplicate prompts appearing 3+ times with no +// matching skill keyword (the user keeps asking for the same thing by hand) +// - tool_retry_loops: 3+ consecutive failed calls of the same tool against +// the same file within a trace (the model keeps fighting the same wall) + +const REPEAT_THRESHOLD = 3; +const RETRY_THRESHOLD = 3; +const SIMILARITY_THRESHOLD = 0.5; + +function _wordSet(text) { + return new Set( + String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 2) + ); +} + +function _jaccard(a, b) { + if (a.size === 0 && b.size === 0) return 0; + let inter = 0; + for (const w of a) if (b.has(w)) inter++; + return inter / (a.size + b.size - inter); +} + +function _isError(result) { + const s = String(result || ''); + return s.startsWith('✗') || /"error"\s*:/.test(s) || /^Error[:\s]/.test(s); +} + +// Group traces whose prompts are near-duplicates (Jaccard on word sets). +function _findRepeatedPatterns(traces, skillKeywords) { + const groups = []; // { words, prompts, traceIds } + for (const t of traces) { + const words = _wordSet(t.prompt); + if (words.size === 0) continue; + let placed = false; + for (const g of groups) { + if (_jaccard(words, g.words) >= SIMILARITY_THRESHOLD) { + g.prompts.push(t.prompt); + g.traceIds.push(t.id); + for (const w of words) g.words.add(w); + placed = true; + break; + } + } + if (!placed) groups.push({ words, prompts: [t.prompt], traceIds: [t.id] }); + } + + return groups + .filter(g => g.prompts.length >= REPEAT_THRESHOLD) + // Skip patterns a skill already covers (any keyword hits the group words) + .filter(g => !skillKeywords.some(kw => g.words.has(String(kw).toLowerCase()))) + .map(g => ({ + pattern: g.prompts[0].slice(0, 120), + count: g.prompts.length, + traceIds: g.traceIds, + })); +} + +// Detect consecutive failed calls of the same tool+file within each trace. +function _findToolRetryLoops(traces) { + const loops = []; + for (const t of traces) { + let runTool = null, runFile = null, failCount = 0; + const flush = () => { + if (failCount >= RETRY_THRESHOLD) { + loops.push({ tool: runTool, file: runFile, failCount, traceIds: [t.id] }); + } + runTool = null; runFile = null; failCount = 0; + }; + for (const step of t.steps || []) { + if (step.type !== 'tool_call') continue; + let file = ''; + try { + const args = typeof step.args === 'string' ? JSON.parse(step.args) : (step.args || {}); + file = args.path || args.file || ''; + } catch {} + const failed = _isError(step.result); + if (failed && step.name === runTool && file === runFile) { + failCount++; + } else { + flush(); + if (failed) { runTool = step.name; runFile = file; failCount = 1; } + } + } + flush(); + } + return loops; +} + +/** + * @param {object[]} traces - full trace objects (TraceRecorder.load shape) + * @param {object} options - { skillKeywords: string[] } keywords of existing skills + * @returns FrictionReport + */ +function extractFrictionSignals(traces, options = {}) { + const skillKeywords = options.skillKeywords || []; + const safe = (traces || []).filter(t => t && typeof t === 'object'); + return { + repeated_patterns: _findRepeatedPatterns(safe, skillKeywords), + tool_retry_loops: _findToolRetryLoops(safe), + analyzed_traces: safe.length, + }; +} + +// Compact text rendering of a friction report for the LLM prompt — counts +// and short descriptions only, never full trace content (budget guard). +function formatReportForPrompt(report) { + const lines = []; + for (const p of report.repeated_patterns) { + lines.push(`- Repeated request (${p.count}x): "${p.pattern}"`); + } + for (const l of report.tool_retry_loops) { + lines.push(`- Tool retry loop: ${l.tool} failed ${l.failCount}x in a row on ${l.file || '(no file)'}`); + } + return lines.join('\n').slice(0, 2000); +} + +module.exports = { extractFrictionSignals, formatReportForPrompt }; diff --git a/src/plugins/skills.js b/src/plugins/skills.js index d88354c7..c4f5206a 100644 --- a/src/plugins/skills.js +++ b/src/plugins/skills.js @@ -83,6 +83,9 @@ class SkillManager { } for (const entry of entries) { if (entry.isDirectory()) { + // drafts/ is quarantined — evolver proposals live there until a + // human promotes them (/evolve promote ). Never auto-load. + if (entry.name === 'drafts') continue; // //SKILL.md inside a standard skill dir — users following // the Claude Code layout expect this to work (closes #81) this._loadSkillFolder(path.join(dir, entry.name), entry.name); @@ -244,6 +247,33 @@ class SkillManager { return skill; } + // Promote a quarantined draft (.smallcode/skills/drafts/.md) into + // the live project skill dir and load it. Returns the new path or null. + promoteDraft(name) { + const safe = String(name || '').replace(/[^a-z0-9-_]/gi, ''); + if (!safe) return null; + const draftsDir = path.join(this.projectDir, '.smallcode', 'skills', 'drafts'); + const source = path.join(draftsDir, `${safe}.md`); + if (!fs.existsSync(source)) return null; + const target = path.join(this.projectDir, '.smallcode', 'skills', `${safe}.md`); + if (fs.existsSync(target)) return null; // never overwrite a live skill + fs.renameSync(source, target); + this._ingestFile(target, `${safe}.md`, path.dirname(target), safe, 'flat'); + return target; + } + + // List quarantined drafts (names only) + listDrafts() { + const draftsDir = path.join(this.projectDir, '.smallcode', 'skills', 'drafts'); + try { + return fs.readdirSync(draftsDir) + .filter(f => f.endsWith('.md')) + .map(f => f.replace(/\.md$/i, '')); + } catch { + return []; + } + } + // Remove a skill remove(name) { const skill = this.skills.get(name); diff --git a/src/tui/fullscreen.js b/src/tui/fullscreen.js index 6a4c935f..5dd8c02a 100644 --- a/src/tui/fullscreen.js +++ b/src/tui/fullscreen.js @@ -192,6 +192,7 @@ class FullScreenTUI { { cmd: '/cognition', alias: null, desc: 'MarrowScript cognition status' }, { cmd: '/mcp', alias: null, desc: 'Connected MCP servers' }, { cmd: '/skill', alias: null, desc: 'Manage reusable skills' }, + { cmd: '/evolve', alias: null, desc: 'Propose skill from session friction' }, { cmd: '/plugin', alias: null, desc: 'Manage plugins' }, { cmd: '/sessions', alias: null, desc: 'List/resume sessions' }, { cmd: '/session', alias: null, desc: 'Parallel sessions' }, diff --git a/test/evolver.test.js b/test/evolver.test.js new file mode 100644 index 00000000..ffb42a7a --- /dev/null +++ b/test/evolver.test.js @@ -0,0 +1,240 @@ +'use strict'; + +// SmallCode — Evolver (create-mode) tests +// Pins the deterministic mechanics behind /evolve: proposal validation, +// quarantined draft writing, the structural 1-create-per-run cap, friction +// extraction from traces, and the SkillManager drafts quarantine. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const evolver = require('../src/plugins/evolver'); +const { extractFrictionSignals, formatReportForPrompt } = require('../src/plugins/friction_analyzer'); +const { appendEntry, readEntries } = require('../src/plugins/audit_log'); +const { SkillManager } = require('../src/plugins/skills'); + +function freshProject() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'sc-evolver-')); +} + +function trace(id, prompt, steps = []) { + return { id, prompt, steps, tokens: { prompt: 0, completion: 0 } }; +} + +function failedStep(tool, file) { + return { type: 'tool_call', name: tool, args: JSON.stringify({ path: file }), result: '✗ failed' }; +} + +// ── Proposal building + validation ─────────────────────────────────────── + +test('buildSkillProposal returns a complete create proposal', () => { + const p = evolver.buildSkillProposal('my-skill', 'does things', 'Body here.', { + trigger: 'match', keywords: ['foo'], rationale: 'seen 3x', + }); + assert.equal(p.kind, 'create'); + assert.equal(p.artefact, 'skill'); + assert.equal(p.trigger, 'match'); + assert.deepEqual(p.keywords, ['foo']); +}); + +test('validateProposal accepts a valid proposal', () => { + const p = evolver.buildSkillProposal('ok-name', 'desc', 'body'); + assert.deepEqual(evolver.validateProposal(p), []); +}); + +test('validateProposal rejects bad names, empty fields, newline descriptions', () => { + const bad = (over) => evolver.validateProposal({ + ...evolver.buildSkillProposal('ok', 'desc', 'body'), ...over, + }); + assert.ok(bad({ name: 'has space' }).length > 0); + assert.ok(bad({ name: '../traverse' }).length > 0); + assert.ok(bad({ name: '' }).length > 0); + assert.ok(bad({ description: '' }).length > 0); + assert.ok(bad({ description: 'line1\nline2' }).length > 0, 'newline = frontmatter injection'); + assert.ok(bad({ body: ' ' }).length > 0); + assert.ok(bad({ trigger: 'bogus' }).length > 0); +}); + +test('validateProposal requires keywords for match trigger', () => { + const p = evolver.buildSkillProposal('m', 'd', 'b', { trigger: 'match', keywords: [] }); + assert.ok(evolver.validateProposal(p).length > 0); +}); + +// ── Collision check ─────────────────────────────────────────────────────── + +test('checkNameCollision finds existing flat and draft skills', () => { + const dir = freshProject(); + const skillsDir = path.join(dir, '.smallcode', 'skills'); + fs.mkdirSync(path.join(skillsDir, 'drafts'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'live-skill.md'), '---\nname: live-skill\n---\nx'); + fs.writeFileSync(path.join(skillsDir, 'drafts', 'pending.md'), '---\nname: pending\n---\nx'); + + assert.ok(evolver.checkNameCollision('live-skill', dir)); + assert.ok(evolver.checkNameCollision('pending', dir)); + assert.equal(evolver.checkNameCollision('brand-new', dir), null); +}); + +// ── Draft writing + cap ─────────────────────────────────────────────────── + +test('writeDraft writes to drafts/ quarantine with frontmatter', () => { + const dir = freshProject(); + const p = evolver.buildSkillProposal('drafted', 'a draft', 'Draft body.', { rationale: 'why' }); + const target = evolver.writeDraft(p, dir); + assert.match(target, /[\\/]drafts[\\/]drafted\.md$/); + const content = fs.readFileSync(target, 'utf-8'); + assert.match(content, /^---\nname: drafted\n/); + assert.match(content, /Draft body\./); + assert.match(content, /Rationale: why/); +}); + +test('writeDraft refuses invalid proposals', () => { + const dir = freshProject(); + assert.throws(() => evolver.writeDraft({ artefact: 'skill', name: 'x y', body: 'b' }, dir)); +}); + +test('EvolverRun allows one create, raises on the second', () => { + const dir = freshProject(); + const run = new evolver.EvolverRun(); + run.writeDraft(evolver.buildSkillProposal('first', 'd', 'b'), dir); + assert.throws( + () => run.writeDraft(evolver.buildSkillProposal('second', 'd', 'b'), dir), + evolver.ProposalCapExceededError + ); + assert.equal(run.createsSoFar, 1); +}); + +// ── Friction analysis ───────────────────────────────────────────────────── + +test('extractFrictionSignals returns empty report for no traces', () => { + const r = extractFrictionSignals([]); + assert.deepEqual(r.repeated_patterns, []); + assert.deepEqual(r.tool_retry_loops, []); + assert.equal(r.analyzed_traces, 0); +}); + +test('three near-identical prompts flag a repeated pattern', () => { + const traces = [ + trace('a1', 'convert this csv file to json format'), + trace('a2', 'convert the csv file into json format please'), + trace('a3', 'csv file convert to json format again'), + trace('b1', 'write unit tests for the auth module'), + ]; + const r = extractFrictionSignals(traces); + assert.equal(r.repeated_patterns.length, 1); + assert.equal(r.repeated_patterns[0].count, 3); + assert.deepEqual(r.repeated_patterns[0].traceIds.sort(), ['a1', 'a2', 'a3']); +}); + +test('repeated pattern covered by an existing skill keyword is suppressed', () => { + const traces = [ + trace('a1', 'convert this csv file to json format'), + trace('a2', 'convert the csv file into json format please'), + trace('a3', 'csv file convert to json format again'), + ]; + const r = extractFrictionSignals(traces, { skillKeywords: ['csv'] }); + assert.equal(r.repeated_patterns.length, 0); +}); + +test('three consecutive same-tool failures flag a retry loop', () => { + const t = trace('t1', 'fix the parser', [ + failedStep('patch', 'src/parser.js'), + failedStep('patch', 'src/parser.js'), + failedStep('patch', 'src/parser.js'), + ]); + const r = extractFrictionSignals([t]); + assert.equal(r.tool_retry_loops.length, 1); + assert.equal(r.tool_retry_loops[0].failCount, 3); + assert.equal(r.tool_retry_loops[0].tool, 'patch'); +}); + +test('interrupted failures do not flag a retry loop', () => { + const t = trace('t1', 'fix it', [ + failedStep('patch', 'a.js'), + failedStep('patch', 'a.js'), + { type: 'tool_call', name: 'read_file', args: '{"path":"a.js"}', result: 'content' }, + failedStep('patch', 'a.js'), + ]); + const r = extractFrictionSignals([t]); + assert.equal(r.tool_retry_loops.length, 0); +}); + +test('formatReportForPrompt stays compact', () => { + const r = extractFrictionSignals([ + trace('a1', 'x'.repeat(500) + ' aaa bbb ccc'), + ]); + assert.ok(formatReportForPrompt(r).length <= 2000); +}); + +// ── Drafts quarantine in SkillManager ───────────────────────────────────── + +test('SkillManager never auto-loads skills from drafts/', () => { + const dir = freshProject(); + const draftsDir = path.join(dir, '.smallcode', 'skills', 'drafts'); + fs.mkdirSync(draftsDir, { recursive: true }); + fs.writeFileSync(path.join(draftsDir, 'lurker.md'), '---\nname: lurker\ntrigger: auto\n---\nshould not load'); + + const sm = new SkillManager(dir); + assert.equal(sm.get('lurker'), null, 'draft must stay quarantined'); +}); + +test('promoteDraft moves draft live and a fresh SkillManager loads it', () => { + const dir = freshProject(); + evolver.writeDraft(evolver.buildSkillProposal('riser', 'promoted skill', 'Now live.'), dir); + + const sm = new SkillManager(dir); + assert.equal(sm.get('riser'), null); + const target = sm.promoteDraft('riser'); + assert.ok(target); + assert.ok(sm.get('riser'), 'promoted skill loads in the same manager'); + + const sm2 = new SkillManager(dir); + assert.ok(sm2.get('riser'), 'promoted skill loads in a fresh manager'); + assert.equal(sm2.listDrafts().length, 0); +}); + +test('promoteDraft never overwrites an existing live skill', () => { + const dir = freshProject(); + const skillsDir = path.join(dir, '.smallcode', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'taken.md'), '---\nname: taken\n---\noriginal'); + evolver.writeDraft(evolver.buildSkillProposal('taken', 'd', 'impostor'), dir); + + const sm = new SkillManager(dir); + assert.equal(sm.promoteDraft('taken'), null); + assert.match(fs.readFileSync(path.join(skillsDir, 'taken.md'), 'utf-8'), /original/); +}); + +test('listDrafts reports quarantined names', () => { + const dir = freshProject(); + evolver.writeDraft(evolver.buildSkillProposal('one', 'd', 'b'), dir); + const sm = new SkillManager(dir); + assert.deepEqual(sm.listDrafts(), ['one']); +}); + +// ── Audit log ───────────────────────────────────────────────────────────── + +test('audit log appends and reads back entries', () => { + const dir = freshProject(); + const file = path.join(dir, '.smallcode', 'evolver-audit.jsonl'); + appendEntry(file, { ts: 't1', kind: 'create', name: 'a' }); + appendEntry(file, { ts: 't2', kind: 'create', name: 'b' }); + const entries = readEntries(file); + assert.equal(entries.length, 2); + assert.equal(entries[1].name, 'b'); +}); + +test('logCreateEvent writes a well-formed audit row', () => { + const dir = freshProject(); + const file = path.join(dir, '.smallcode', 'evolver-audit.jsonl'); + const p = evolver.buildSkillProposal('logged', 'd', 'b', { rationale: 'because' }); + evolver.logCreateEvent(file, p, 'because', ['t1', 't2']); + const [e] = readEntries(file); + assert.equal(e.kind, 'create'); + assert.equal(e.artefact, 'skill'); + assert.equal(e.name, 'logged'); + assert.deepEqual(e.source_traces, ['t1', 't2']); + assert.ok(e.ts); +}); From 7095ce325ba0206224bdf5bb727173b17c461117 Mon Sep 17 00:00:00 2001 From: shuff57 <62350898+shuff57@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:52:26 -0700 Subject: [PATCH 3/5] fix(evolver): stopword filtering in prompt clustering Field regression: rephrased prompts with filler drift (another/please/new) failed to cluster because stopwords diluted Jaccard below threshold. Real prompts from a live session pinned as a test. --- src/plugins/friction_analyzer.js | 12 +++++++++++- test/evolver.test.js | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/plugins/friction_analyzer.js b/src/plugins/friction_analyzer.js index 2ba25bed..0165db58 100644 --- a/src/plugins/friction_analyzer.js +++ b/src/plugins/friction_analyzer.js @@ -13,9 +13,19 @@ const REPEAT_THRESHOLD = 3; const RETRY_THRESHOLD = 3; const SIMILARITY_THRESHOLD = 0.5; +// Filler words carry no task identity but dilute Jaccard similarity — +// "another seating chart please" must cluster with "a seating chart for..." +const STOPWORDS = new Set([ + 'the', 'and', 'for', 'with', 'that', 'this', 'these', 'those', 'from', + 'into', 'onto', 'please', 'can', 'you', 'could', 'would', 'will', + 'another', 'again', 'new', 'now', 'just', 'some', 'all', 'any', + 'make', 'give', 'get', 'want', 'need', 'like', +]); + function _wordSet(text) { return new Set( - String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 2) + String(text || '').toLowerCase().split(/[^a-z0-9]+/) + .filter(w => w.length > 2 && !STOPWORDS.has(w)) ); } diff --git a/test/evolver.test.js b/test/evolver.test.js index ffb42a7a..4fcf3d0e 100644 --- a/test/evolver.test.js +++ b/test/evolver.test.js @@ -128,6 +128,19 @@ test('three near-identical prompts flag a repeated pattern', () => { assert.deepEqual(r.repeated_patterns[0].traceIds.sort(), ['a1', 'a2', 'a3']); }); +test('rephrased prompts with filler-word drift still cluster (field regression)', () => { + // Exact prompts from a real session that failed to cluster before + // stopword filtering: the third drops the names and adds filler. + const traces = [ + trace('s1', 'generate a random seating chart for my classroom students Ana, Ben, Cara, Dan, Eli and Fay'), + trace('s2', 'generate a new random seating chart for the classroom students Ana, Ben, Cara, Dan, Eli and Fay'), + trace('s3', 'generate another random seating chart for my classroom students please'), + ]; + const r = extractFrictionSignals(traces); + assert.equal(r.repeated_patterns.length, 1); + assert.equal(r.repeated_patterns[0].count, 3); +}); + test('repeated pattern covered by an existing skill keyword is suppressed', () => { const traces = [ trace('a1', 'convert this csv file to json format'), From 4896c27ec01e6af18ec0428304ef3e400f896ce5 Mon Sep 17 00:00:00 2001 From: shuff57 <62350898+shuff57@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:48:17 -0700 Subject: [PATCH 4/5] feat(skills): lazy index-first loading + use_skill tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SkillManager now reads only frontmatter on startup (_index Map) and loads bodies on demand via _loadBody(), cached in skills Map. This cuts per-turn skill injection from ~60k chars (all bodies) to ~240 chars (compact index) for a typical 30-skill install. New surface: getIndex() flat list, formatSkillIndex/formatSkillResult in skill_index_formatter.js, use_skill tool (executor + tools.js). getSkillContext() injects the index always; auto-matched bodies append after, subject to the existing 4000-char cap. Public API (get/list/getAutoSkills/formatForPrompt/add/remove/ promoteDraft/listDrafts) is unchanged — all 335 tests pass. Rejected: inject all bodies always | O(skills) context cost per turn Constraint: existing tests must pass unmodified Confidence: high Scope-risk: moderate Not-tested: live use_skill call by real model (requires interactive session) --- bin/executor.js | 18 ++ bin/smallcode.js | 25 ++- bin/tools.js | 1 + src/plugins/skill_index_formatter.js | 39 +++++ src/plugins/skills.js | 235 ++++++++++++++++++++------- test/skill_lazy.test.js | 190 ++++++++++++++++++++++ 6 files changed, 443 insertions(+), 65 deletions(-) create mode 100644 src/plugins/skill_index_formatter.js create mode 100644 test/skill_lazy.test.js diff --git a/bin/executor.js b/bin/executor.js index 87314a30..091aa28a 100644 --- a/bin/executor.js +++ b/bin/executor.js @@ -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}` }; diff --git a/bin/smallcode.js b/bin/smallcode.js index d2ac8768..c5be9c50 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -444,6 +444,7 @@ async function executeTool(name, args) { flags, config, tui, + skillManager, }); try { if (dedup) dedup.record(name, args, result); } catch {} @@ -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 ''; } diff --git a/bin/tools.js b/bin/tools.js index c34191a1..9682fd49 100644 --- a/bin/tools.js +++ b/bin/tools.js @@ -32,6 +32,7 @@ const TOOLS = [ { type: 'function', function: { name: 'contract_assert_pass', description: 'Mark a contract assertion as passed, with command-line evidence. Use the assertion id from contract_status (e.g. "a01"). evidence should be a short (<240 char) summary of what was run and what it returned.', parameters: { type: 'object', properties: { assertion_id: { type: 'string', description: 'Assertion id (e.g. a01)' }, evidence: { type: 'string', description: 'Short summary of command output proving the assertion holds' }, command: { type: 'string', description: 'The command run (optional)' }, exit_code: { type: 'integer', description: 'Exit code of the command (optional)' } }, required: ['assertion_id'] } } }, { type: 'function', function: { name: 'contract_assert_fail', description: 'Mark a contract assertion as failed, with evidence. Used when a check ran and the result was wrong — not for skipping checks.', parameters: { type: 'object', properties: { assertion_id: { type: 'string', description: 'Assertion id (e.g. a01)' }, evidence: { type: 'string', description: 'Short summary of why the check failed' }, command: { type: 'string', description: 'The command run (optional)' }, exit_code: { type: 'integer', description: 'Exit code of the command (optional)' } }, required: ['assertion_id', 'evidence'] } } }, { type: 'function', function: { name: 'contract_assert_skip', description: 'Mark an assertion as skipped (not applicable in current scope). Skipped assertions count as resolved for the done-guard.', parameters: { type: 'object', properties: { assertion_id: { type: 'string', description: 'Assertion id' }, reason: { type: 'string', description: 'Why this assertion is being skipped' } }, required: ['assertion_id', 'reason'] } } }, + { type: 'function', function: { name: 'use_skill', description: 'Load the full body of a skill by name. Use this when the skill index lists a skill relevant to your task. Returns the full skill content plus any related skill descriptions.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Skill name from the index' } }, required: ['name'] } } }, ]; // ─── Provider Tools ───────────────────────────────────────────────────────── diff --git a/src/plugins/skill_index_formatter.js b/src/plugins/skill_index_formatter.js new file mode 100644 index 00000000..defc4258 --- /dev/null +++ b/src/plugins/skill_index_formatter.js @@ -0,0 +1,39 @@ +'use strict'; + +// SmallCode — Skill index formatter +// Produces a compact index string (one line per skill, ~8 tokens each) suitable +// for always-injecting into the system prompt, plus a full-body formatter for +// use_skill results that includes related skill names/descriptions (not bodies). + +/** + * Format a flat index of skills — one line per skill. + * @param {Array<{name:string, description:string, trigger:string, keywords:string[]}>} entries + * @returns {string} + */ +function formatSkillIndex(entries) { + if (!entries || entries.length === 0) return ''; + const lines = entries.map(e => { + const kw = e.keywords && e.keywords.length ? ` [${e.keywords.join(',')}]` : ''; + const desc = e.description ? ` — ${e.description}` : ''; + return ` ${e.name}${desc}${kw}`; + }); + return '\n\nAvailable skills (call use_skill to load):\n' + lines.join('\n'); +} + +/** + * Format a loaded skill body for the use_skill response. + * Appends brief related-skill entries (name + description only, not body). + * @param {object} skill — {name, description, content, keywords, trigger} + * @param {Array<{name:string, description:string}>} relatedEntries — index entries for related skills + * @returns {string} + */ +function formatSkillResult(skill, relatedEntries) { + let out = `[skill:${skill.name}]\n${skill.content}`; + if (relatedEntries && relatedEntries.length > 0) { + const rel = relatedEntries.map(e => ` ${e.name}${e.description ? ' — ' + e.description : ''}`).join('\n'); + out += `\n\nRelated skills:\n${rel}`; + } + return out; +} + +module.exports = { formatSkillIndex, formatSkillResult }; diff --git a/src/plugins/skills.js b/src/plugins/skills.js index c4f5206a..68205b86 100644 --- a/src/plugins/skills.js +++ b/src/plugins/skills.js @@ -21,6 +21,10 @@ // previously skipped silently (closes #81). README-style files are ignored. // // Frontmatter accepts both LF and CRLF line endings (closes #52). +// +// Lazy loading: index entries (frontmatter only) are stored in _index Map. +// Bodies are loaded on demand via _loadBody(name) and cached into skills Map. +// getIndex() returns flat IndexEntry list for prompt injection. const fs = require('fs'); const path = require('path'); @@ -31,10 +35,16 @@ const KV_RE = /^(\w+)\s*:\s*(.+?)\s*$/; // Docs that live alongside skills but aren't skills themselves const NON_SKILL_MD = /^(readme|changelog|license|contributing)\.md$/i; +// Max bytes to scan for frontmatter before falling back to full read. +const FRONTMATTER_SCAN_BYTES = 2048; +// Max lines to scan for frontmatter end marker. +const FRONTMATTER_SCAN_LINES = 50; + class SkillManager { constructor(projectDir) { this.projectDir = projectDir || process.cwd(); - this.skills = new Map(); // name → skill object + this.skills = new Map(); // name → fully-loaded skill object (cached) + this._index = new Map(); // name → IndexEntry (frontmatter + path, no body) this._load(); } @@ -130,87 +140,191 @@ class SkillManager { this._ingestFile(skillFile, path.basename(skillFile), skillDir, name, 'nested'); } + // Read only enough of the file to extract frontmatter (index-only load). + // Returns { frontmatter: string|null, bodyStart: number } — bodyStart is + // the byte offset where the body begins (after the closing ---). + // Falls back to a full read when the file is small enough or frontmatter + // spans more than FRONTMATTER_SCAN_BYTES. + _readFrontmatterOnly(filePath) { + try { + // Read a limited slice first. + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(FRONTMATTER_SCAN_BYTES); + const bytesRead = fs.readSync(fd, buf, 0, FRONTMATTER_SCAN_BYTES, 0); + fs.closeSync(fd); + const chunk = buf.slice(0, bytesRead).toString('utf-8'); + + if (!chunk.startsWith('---')) { + // No frontmatter — full content is body; return null so caller full-reads. + return { frontmatter: null, hasMore: bytesRead === FRONTMATTER_SCAN_BYTES }; + } + + // Find closing --- within FRONTMATTER_SCAN_LINES lines + const lines = chunk.split(/\r?\n/); + let closeIdx = -1; + for (let i = 1; i < Math.min(lines.length, FRONTMATTER_SCAN_LINES); i++) { + if (lines[i].trimEnd() === '---') { closeIdx = i; break; } + } + if (closeIdx === -1) { + // Frontmatter not closed within scan window — fall back to full read. + return { frontmatter: null, hasMore: true }; + } + + const frontmatter = lines.slice(1, closeIdx).join('\n'); + return { frontmatter, hasMore: bytesRead === FRONTMATTER_SCAN_BYTES }; + } catch { + return { frontmatter: null, hasMore: false }; + } + } + _ingestFile(filePath, filename, dir, defaultName, origin) { + // Index-only path: read frontmatter cheaply, store as index entry. + // Body is loaded lazily on first get(). + const { frontmatter, hasMore } = this._readFrontmatterOnly(filePath); + + let meta = {}; + if (frontmatter !== null) { + meta = this._parseMeta(frontmatter); + } + + const name = meta.name || defaultName || filename.replace(/\.md$/i, ''); + + const entry = { + name, + trigger: meta.trigger || 'manual', + keywords: Array.isArray(meta.keywords) ? meta.keywords : [], + description: meta.description || '', + tags: Array.isArray(meta.tags) ? meta.tags : [], + related: Array.isArray(meta.related) ? meta.related : [], + path: filePath, + origin: origin || (defaultName ? 'nested' : 'flat'), + // hasFrontmatter: whether the file had a --- block + _hasFrontmatter: frontmatter !== null, + // If the file fits in our scan and has frontmatter, we know + // the body wasn't loaded yet. Track that. + _bodyLoaded: false, + }; + + this._index.set(name, entry); + // Remove any stale cached body for same name (precedence override) + this.skills.delete(name); + } + + _parseMeta(frontmatter) { + const meta = {}; + for (const rawLine of frontmatter.split(/\r?\n/)) { + const m = rawLine.match(KV_RE); + if (!m) continue; + let value = m[2].trim(); + if (value.startsWith('[') && value.endsWith(']')) { + value = value.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean); + } + meta[m[1]] = value; + } + return meta; + } + + // Load the full body for a named skill, populate this.skills cache. + _loadBody(name) { + const entry = this._index.get(name); + if (!entry) return null; + if (entry._bodyLoaded && this.skills.has(name)) return this.skills.get(name); + let content; try { - content = fs.readFileSync(filePath, 'utf-8'); + content = fs.readFileSync(entry.path, 'utf-8'); } catch { - return; + return null; } - const skill = this._parse(content, filename, dir, defaultName, origin); - if (skill) this.skills.set(skill.name, skill); - } - _parse(content, filename, dir, defaultName, origin) { - // Parse YAML frontmatter (CRLF + LF tolerant — closes #52) const fmMatch = content.match(FM_RE); - let frontmatter = ''; let body = content; + let meta = {}; if (fmMatch) { - frontmatter = fmMatch[1]; + meta = this._parseMeta(fmMatch[1]); body = fmMatch[2]; - } else if (!defaultName) { - // Files without frontmatter and no derivable name aren't skills. - // Flat + nested loaders always pass a defaultName, so frontmatter-less - // files load as manual skills (closes #81); README-style files are - // filtered by name in _loadFlat. - return null; - } - - // Tiny YAML parser — no dep needed - const meta = {}; - if (frontmatter) { - for (const rawLine of frontmatter.split(/\r?\n/)) { - const m = rawLine.match(KV_RE); - if (!m) continue; - let value = m[2].trim(); - if (value.startsWith('[') && value.endsWith(']')) { - value = value.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean); - } - meta[m[1]] = value; - } + } else if (!entry._hasFrontmatter) { + // No frontmatter — full file is body (manual trigger, named by filename/dir) + body = content; } - return { - name: meta.name || defaultName || filename.replace(/\.md$/i, ''), - trigger: meta.trigger || 'manual', - keywords: Array.isArray(meta.keywords) ? meta.keywords : [], + const skill = { + name: meta.name || entry.name, + trigger: meta.trigger || entry.trigger, + keywords: Array.isArray(meta.keywords) ? meta.keywords : entry.keywords, + description: meta.description || entry.description || '', + tags: Array.isArray(meta.tags) ? meta.tags : entry.tags, + related: Array.isArray(meta.related) ? meta.related : entry.related, content: body.trim(), - path: path.join(dir, filename), - origin: origin || (defaultName ? 'nested' : 'flat'), + path: entry.path, + origin: entry.origin, }; + + entry._bodyLoaded = true; + this.skills.set(name, skill); + return skill; } - // Get all skills + // Get all skills — returns index entries with lazy-loaded bodies for callers + // that need content. list() does NOT load bodies (index only). list() { - return [...this.skills.values()].map(s => ({ - name: s.name, - trigger: s.trigger, - keywords: s.keywords, - preview: s.content.slice(0, 80) + (s.content.length > 80 ? '...' : ''), - origin: s.origin || 'flat', + return [...this._index.values()].map(e => ({ + name: e.name, + trigger: e.trigger, + keywords: e.keywords, + preview: this._getPreview(e), + origin: e.origin || 'flat', })); } - // Get a skill by name + _getPreview(entry) { + // Return preview from cached body if available; otherwise a short placeholder. + if (entry._bodyLoaded && this.skills.has(entry.name)) { + const body = this.skills.get(entry.name).content; + return body.slice(0, 80) + (body.length > 80 ? '...' : ''); + } + // Avoid loading body just for list() — return description or empty + return entry.description || ''; + } + + // Get a skill by name — lazily loads body on first call. get(name) { - return this.skills.get(name) || null; + if (this.skills.has(name)) return this.skills.get(name); + if (!this._index.has(name)) return null; + return this._loadBody(name); } - // Get skills that should auto-inject for a given message + // Get skills that should auto-inject for a given message. + // Only checks index entries (trigger/keywords) — avoids loading bodies + // until caller needs content. getAutoSkills(message) { const msg = (message || '').toLowerCase(); const results = []; - for (const skill of this.skills.values()) { - if (skill.trigger === 'auto') { - results.push(skill); - } else if (skill.trigger === 'match' && skill.keywords.length > 0) { - const match = skill.keywords.some(kw => msg.includes(String(kw).toLowerCase())); - if (match) results.push(skill); + for (const entry of this._index.values()) { + if (entry.trigger === 'auto') { + results.push(this._loadBody(entry.name)); + } else if (entry.trigger === 'match' && entry.keywords.length > 0) { + const match = entry.keywords.some(kw => msg.includes(String(kw).toLowerCase())); + if (match) results.push(this._loadBody(entry.name)); } } - return results; + return results.filter(Boolean); + } + + // Return flat IndexEntry list for prompt injection (no bodies loaded). + // { name, description, trigger, keywords, tags, related, path, origin } + getIndex() { + return [...this._index.values()].map(e => ({ + name: e.name, + description: e.description, + trigger: e.trigger, + keywords: e.keywords, + tags: e.tags, + related: e.related, + path: e.path, + origin: e.origin, + })); } // Create a new skill in the project's .smallcode/skills directory @@ -239,10 +353,16 @@ class SkillManager { name, trigger, keywords, + description: options.description || '', + tags: options.tags || [], + related: options.related || [], content, path: filePath, origin: 'flat', + _hasFrontmatter: true, + _bodyLoaded: true, }; + this._index.set(name, skill); this.skills.set(name, skill); return skill; } @@ -276,11 +396,12 @@ class SkillManager { // Remove a skill remove(name) { - const skill = this.skills.get(name); - if (!skill) return false; - if (fs.existsSync(skill.path)) { - try { fs.unlinkSync(skill.path); } catch {} + const entry = this._index.get(name) || this.skills.get(name); + if (!entry) return false; + if (fs.existsSync(entry.path)) { + try { fs.unlinkSync(entry.path); } catch {} } + this._index.delete(name); this.skills.delete(name); return true; } diff --git a/test/skill_lazy.test.js b/test/skill_lazy.test.js new file mode 100644 index 00000000..59da7938 --- /dev/null +++ b/test/skill_lazy.test.js @@ -0,0 +1,190 @@ +'use strict'; + +// SmallCode — Lazy skill loading tests +// Verifies index-first SkillManager, lazy body loading, getIndex() fields, +// formatter output, and backward compatibility with existing callers. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { SkillManager } = require('../src/plugins/skills'); +const { formatSkillIndex, formatSkillResult } = require('../src/plugins/skill_index_formatter'); + +function freshProject() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'sc-lazy-')); +} + +function write(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +// ── Index-only startup ──────────────────────────────────────────────────────── + +test('index is populated on construction without loading bodies', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'alpha.md'), + '---\nname: alpha\ntrigger: manual\ndescription: does alpha things\n---\nbody text here'); + + const sm = new SkillManager(dir); + // _index must have the entry + assert.ok(sm._index.has('alpha'), '_index should have alpha'); + // skills (body cache) should NOT have it yet + assert.ok(!sm.skills.has('alpha'), 'body cache should be empty before get()'); +}); + +test('getIndex() returns expected fields without loading bodies', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'beta.md'), + '---\nname: beta\ntrigger: match\nkeywords: [foo, bar]\ndescription: beta desc\ntags: [t1]\nrelated: [alpha]\n---\nbeta body'); + + const sm = new SkillManager(dir); + const idx = sm.getIndex(); + const entry = idx.find(e => e.name === 'beta'); + assert.ok(entry, 'getIndex should return beta'); + assert.equal(entry.name, 'beta'); + assert.equal(entry.description, 'beta desc'); + assert.equal(entry.trigger, 'match'); + assert.deepEqual(entry.keywords, ['foo', 'bar']); + assert.deepEqual(entry.tags, ['t1']); + assert.deepEqual(entry.related, ['alpha']); + assert.ok(entry.path); + assert.equal(entry.origin, 'flat'); + // Body should still not be loaded + assert.ok(!sm.skills.has('beta')); +}); + +// ── Lazy get() ──────────────────────────────────────────────────────────────── + +test('get() lazily loads body on first call', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'lazy.md'), + '---\nname: lazy\ntrigger: manual\n---\nthe lazy body content'); + + const sm = new SkillManager(dir); + assert.ok(!sm.skills.has('lazy'), 'body not loaded yet'); + const skill = sm.get('lazy'); + assert.ok(skill, 'get() returns the skill'); + assert.match(skill.content, /the lazy body content/); + assert.ok(sm.skills.has('lazy'), 'body is cached after get()'); +}); + +test('get() caches: second call returns same object', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'cached.md'), + '---\nname: cached\ntrigger: manual\n---\ncached body'); + + const sm = new SkillManager(dir); + const first = sm.get('cached'); + const second = sm.get('cached'); + assert.strictEqual(first, second, 'should return same cached object'); +}); + +test('get() returns null for unknown skill', () => { + const dir = freshProject(); + const sm = new SkillManager(dir); + assert.equal(sm.get('nonexistent'), null); +}); + +// ── Backward compat: public API unchanged ───────────────────────────────────── + +test('list() returns entries with name/trigger/keywords/origin', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'listme.md'), + '---\nname: listme\ntrigger: auto\nkeywords: [x]\n---\nlist body'); + + const sm = new SkillManager(dir); + const items = sm.list(); + const item = items.find(i => i.name === 'listme'); + assert.ok(item); + assert.equal(item.trigger, 'auto'); + assert.deepEqual(item.keywords, ['x']); + assert.equal(item.origin, 'flat'); + // list() should NOT load bodies + assert.ok(!sm.skills.has('listme')); +}); + +test('getAutoSkills() loads bodies only for matched skills', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'always.md'), + '---\nname: always\ntrigger: auto\n---\nauto body'); + write(path.join(dir, '.smallcode', 'skills', 'keyword.md'), + '---\nname: keyword\ntrigger: match\nkeywords: [deploy]\n---\ndeploy body'); + write(path.join(dir, '.smallcode', 'skills', 'nomatch.md'), + '---\nname: nomatch\ntrigger: match\nkeywords: [unrelated]\n---\nnomatch body'); + + const sm = new SkillManager(dir); + const result = sm.getAutoSkills('please deploy the app'); + const names = result.map(s => s.name).sort(); + assert.deepEqual(names, ['always', 'keyword']); + // nomatch should not be loaded + assert.ok(!sm.skills.has('nomatch')); +}); + +// ── Formatter ──────────────────────────────────────────────────────────────── + +test('formatSkillIndex produces one line per skill', () => { + const entries = [ + { name: 'foo', description: 'does foo', trigger: 'manual', keywords: [] }, + { name: 'bar', description: 'does bar', trigger: 'match', keywords: ['baz'] }, + ]; + const out = formatSkillIndex(entries); + assert.ok(out.includes('foo')); + assert.ok(out.includes('bar')); + // Each skill on its own line + const lines = out.split('\n').filter(l => l.includes('foo') || l.includes('bar')); + assert.equal(lines.length, 2); +}); + +test('formatSkillIndex returns empty string for no entries', () => { + assert.equal(formatSkillIndex([]), ''); + assert.equal(formatSkillIndex(null), ''); +}); + +test('formatSkillResult includes body and related names', () => { + const skill = { name: 'main', description: '', content: 'main body content', keywords: [], trigger: 'manual' }; + const related = [ + { name: 'other', description: 'the other skill' }, + ]; + const out = formatSkillResult(skill, related); + assert.ok(out.includes('main body content')); + assert.ok(out.includes('other')); + assert.ok(out.includes('the other skill')); +}); + +test('formatSkillResult with no related entries', () => { + const skill = { name: 's', content: 'solo body', keywords: [], trigger: 'manual', description: '' }; + const out = formatSkillResult(skill, []); + assert.ok(out.includes('solo body')); + assert.ok(!out.includes('Related skills')); +}); + +// ── New frontmatter fields backward compat ──────────────────────────────────── + +test('skills without description/tags/related still load correctly', () => { + const dir = freshProject(); + write(path.join(dir, '.smallcode', 'skills', 'plain.md'), + '---\nname: plain\ntrigger: manual\n---\njust a plain body'); + + const sm = new SkillManager(dir); + const skill = sm.get('plain'); + assert.ok(skill); + assert.equal(skill.description, ''); + assert.deepEqual(skill.tags, []); + assert.deepEqual(skill.related, []); + assert.match(skill.content, /just a plain body/); +}); + +test('add() works and skill is in index immediately', () => { + const dir = freshProject(); + const sm = new SkillManager(dir); + sm.add('added', 'added content', { trigger: 'auto', description: 'an added skill' }); + + assert.ok(sm._index.has('added')); + const skill = sm.get('added'); + assert.ok(skill); + assert.match(skill.content, /added content/); +}); From 406c4fbae1e6d9c11aafc66f71dc6b9fadb9a45c Mon Sep 17 00:00:00 2001 From: shuff57 <62350898+shuff57@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:09:24 -0700 Subject: [PATCH 5/5] fix(skills): route use_skill through tool category filters use_skill was defined in TOOLS but absent from both routers' category whitelists, so the model never saw it in routed mode. The skill index is injected every turn, so the tool rides along in every tool-bearing category (~80 tokens). --- src/compiled/tool_router.js | 19 +++++++++++-------- src/tools/two_stage_router.js | 10 +++++++--- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/compiled/tool_router.js b/src/compiled/tool_router.js index 10371a56..3b39d6d1 100644 --- a/src/compiled/tool_router.js +++ b/src/compiled/tool_router.js @@ -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']; } } diff --git a/src/tools/two_stage_router.js b/src/tools/two_stage_router.js index cd5f4e41..1caf24ec 100644 --- a/src/tools/two_stage_router.js +++ b/src/tools/two_stage_router.js @@ -28,11 +28,15 @@ const TOOL_CATEGORIES = { tools: ['bash', 'run'], }, plan: { - description: 'Load/save project memory, BoneScript compile/check', - tools: ['memory_load', 'memory_remember', 'bone_compile', 'bone_check'], + description: 'Load/save project memory, load skills, BoneScript compile/check', + tools: ['memory_load', 'memory_remember', 'use_skill', 'bone_compile', 'bone_check'], }, }; +// Cross-cutting tools appended to every category in Stage 2 — the skill +// index is injected on every turn, so use_skill must always be callable. +const ALWAYS_TOOLS = ['use_skill']; + /** * Determine routing mode based on model's context window. * @param {number} contextWindow - Model's context length in tokens @@ -80,7 +84,7 @@ function getCategorySelectorTool() { function getToolsForCategory(category, allTools) { const cat = TOOL_CATEGORIES[category]; if (!cat) return allTools; // Unknown category, fall back to all - return allTools.filter(t => cat.tools.includes(t.function.name)); + return allTools.filter(t => cat.tools.includes(t.function.name) || ALWAYS_TOOLS.includes(t.function.name)); } /**