feat: deliver v1.8.0 harness reliability and parity updates

This commit is contained in:
Affaan Mustafa
2026-03-04 14:48:06 -08:00
parent 32e9c293f0
commit 48b883d741
84 changed files with 2990 additions and 725 deletions

View File

@@ -1,14 +1,8 @@
#!/usr/bin/env node
/**
* NanoClaw — Barebones Agent REPL for Everything Claude Code
* NanoClaw v2 — Barebones Agent REPL for Everything Claude Code
*
* A persistent, session-aware AI agent loop that delegates to `claude -p`.
* Zero external dependencies. Markdown-as-database. Synchronous REPL.
*
* Usage:
* node scripts/claw.js
* CLAW_SESSION=my-project node scripts/claw.js
* CLAW_SKILLS=tdd-workflow,security-review node scripts/claw.js
* Zero external dependencies. Session-aware REPL around `claude -p`.
*/
'use strict';
@@ -19,29 +13,25 @@ const os = require('os');
const { spawnSync } = require('child_process');
const readline = require('readline');
// ─── Session name validation ────────────────────────────────────────────────
const SESSION_NAME_RE = /^[a-zA-Z0-9][-a-zA-Z0-9]*$/;
const DEFAULT_MODEL = process.env.CLAW_MODEL || 'sonnet';
const DEFAULT_COMPACT_KEEP_TURNS = 20;
function isValidSessionName(name) {
return typeof name === 'string' && name.length > 0 && SESSION_NAME_RE.test(name);
}
// ─── Storage Adapter (Markdown-as-Database) ─────────────────────────────────
function getClawDir() {
return path.join(os.homedir(), '.claude', 'claw');
}
function getSessionPath(name) {
return path.join(getClawDir(), name + '.md');
return path.join(getClawDir(), `${name}.md`);
}
function listSessions(dir) {
const clawDir = dir || getClawDir();
if (!fs.existsSync(clawDir)) {
return [];
}
if (!fs.existsSync(clawDir)) return [];
return fs.readdirSync(clawDir)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
@@ -50,7 +40,7 @@ function listSessions(dir) {
function loadHistory(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (_err) {
} catch {
return '';
}
}
@@ -58,29 +48,27 @@ function loadHistory(filePath) {
function appendTurn(filePath, role, content, timestamp) {
const ts = timestamp || new Date().toISOString();
const entry = `### [${ts}] ${role}\n${content}\n---\n`;
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.appendFileSync(filePath, entry, 'utf8');
}
// ─── Context & Delegation Pipeline ──────────────────────────────────────────
function normalizeSkillList(raw) {
if (!raw) return [];
if (Array.isArray(raw)) return raw.map(s => String(s).trim()).filter(Boolean);
return String(raw).split(',').map(s => s.trim()).filter(Boolean);
}
function loadECCContext(skillList) {
const raw = skillList !== undefined ? skillList : (process.env.CLAW_SKILLS || '');
if (!raw.trim()) {
return '';
}
const requested = normalizeSkillList(skillList !== undefined ? skillList : process.env.CLAW_SKILLS || '');
if (requested.length === 0) return '';
const names = raw.split(',').map(s => s.trim()).filter(Boolean);
const chunks = [];
for (const name of names) {
for (const name of requested) {
const skillPath = path.join(process.cwd(), 'skills', name, 'SKILL.md');
try {
const content = fs.readFileSync(skillPath, 'utf8');
chunks.push(content);
} catch (_err) {
// Gracefully skip missing skills
chunks.push(fs.readFileSync(skillPath, 'utf8'));
} catch {
// Skip missing skills silently to keep REPL usable.
}
}
@@ -89,38 +77,158 @@ function loadECCContext(skillList) {
function buildPrompt(systemPrompt, history, userMessage) {
const parts = [];
if (systemPrompt) {
parts.push('=== SYSTEM CONTEXT ===\n' + systemPrompt + '\n');
}
if (history) {
parts.push('=== CONVERSATION HISTORY ===\n' + history + '\n');
}
parts.push('=== USER MESSAGE ===\n' + userMessage);
if (systemPrompt) parts.push(`=== SYSTEM CONTEXT ===\n${systemPrompt}\n`);
if (history) parts.push(`=== CONVERSATION HISTORY ===\n${history}\n`);
parts.push(`=== USER MESSAGE ===\n${userMessage}`);
return parts.join('\n');
}
function askClaude(systemPrompt, history, userMessage) {
function askClaude(systemPrompt, history, userMessage, model) {
const fullPrompt = buildPrompt(systemPrompt, history, userMessage);
const args = [];
if (model) {
args.push('--model', model);
}
args.push('-p', fullPrompt);
const result = spawnSync('claude', ['-p', fullPrompt], {
const result = spawnSync('claude', args, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CLAUDECODE: '' },
timeout: 300000 // 5 minute timeout
timeout: 300000,
});
if (result.error) {
return '[Error: ' + result.error.message + ']';
return `[Error: ${result.error.message}]`;
}
if (result.status !== 0 && result.stderr) {
return '[Error: claude exited with code ' + result.status + ': ' + result.stderr.trim() + ']';
return `[Error: claude exited with code ${result.status}: ${result.stderr.trim()}]`;
}
return (result.stdout || '').trim();
}
// ─── REPL Commands ──────────────────────────────────────────────────────────
function parseTurns(history) {
const turns = [];
const regex = /### \[([^\]]+)\] ([^\n]+)\n([\s\S]*?)\n---\n/g;
let match;
while ((match = regex.exec(history)) !== null) {
turns.push({ timestamp: match[1], role: match[2], content: match[3] });
}
return turns;
}
function estimateTokenCount(text) {
return Math.ceil((text || '').length / 4);
}
function getSessionMetrics(filePath) {
const history = loadHistory(filePath);
const turns = parseTurns(history);
const charCount = history.length;
const tokenEstimate = estimateTokenCount(history);
const userTurns = turns.filter(t => t.role === 'User').length;
const assistantTurns = turns.filter(t => t.role === 'Assistant').length;
return {
turns: turns.length,
userTurns,
assistantTurns,
charCount,
tokenEstimate,
};
}
function searchSessions(query, dir) {
const q = String(query || '').toLowerCase().trim();
if (!q) return [];
const sessions = listSessions(dir);
const results = [];
for (const name of sessions) {
const p = getSessionPath(name);
const content = loadHistory(p);
if (!content) continue;
const idx = content.toLowerCase().indexOf(q);
if (idx >= 0) {
const start = Math.max(0, idx - 40);
const end = Math.min(content.length, idx + q.length + 40);
const snippet = content.slice(start, end).replace(/\n/g, ' ');
results.push({ session: name, snippet });
}
}
return results;
}
function compactSession(filePath, keepTurns = DEFAULT_COMPACT_KEEP_TURNS) {
const history = loadHistory(filePath);
if (!history) return false;
const turns = parseTurns(history);
if (turns.length <= keepTurns) return false;
const retained = turns.slice(-keepTurns);
const compactedHeader = `# NanoClaw Compaction\nCompacted at: ${new Date().toISOString()}\nRetained turns: ${keepTurns}/${turns.length}\n\n---\n`;
const compactedTurns = retained.map(t => `### [${t.timestamp}] ${t.role}\n${t.content}\n---\n`).join('');
fs.writeFileSync(filePath, compactedHeader + compactedTurns, 'utf8');
return true;
}
function exportSession(filePath, format, outputPath) {
const history = loadHistory(filePath);
const sessionName = path.basename(filePath, '.md');
const fmt = String(format || 'md').toLowerCase();
if (!history) {
return { ok: false, message: 'No session history to export.' };
}
const dir = path.dirname(filePath);
let out = outputPath;
if (!out) {
out = path.join(dir, `${sessionName}.export.${fmt === 'markdown' ? 'md' : fmt}`);
}
if (fmt === 'md' || fmt === 'markdown') {
fs.writeFileSync(out, history, 'utf8');
return { ok: true, path: out };
}
if (fmt === 'json') {
const turns = parseTurns(history);
fs.writeFileSync(out, JSON.stringify({ session: sessionName, turns }, null, 2), 'utf8');
return { ok: true, path: out };
}
if (fmt === 'txt' || fmt === 'text') {
const turns = parseTurns(history);
const txt = turns.map(t => `[${t.timestamp}] ${t.role}:\n${t.content}\n`).join('\n');
fs.writeFileSync(out, txt, 'utf8');
return { ok: true, path: out };
}
return { ok: false, message: `Unsupported export format: ${format}` };
}
function branchSession(currentSessionPath, newSessionName) {
if (!isValidSessionName(newSessionName)) {
return { ok: false, message: `Invalid branch session name: ${newSessionName}` };
}
const target = getSessionPath(newSessionName);
fs.mkdirSync(path.dirname(target), { recursive: true });
const content = loadHistory(currentSessionPath);
fs.writeFileSync(target, content, 'utf8');
return { ok: true, path: target, session: newSessionName };
}
function skillExists(skillName) {
const p = path.join(process.cwd(), 'skills', skillName, 'SKILL.md');
return fs.existsSync(p);
}
function handleClear(sessionPath) {
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
@@ -132,72 +240,73 @@ function handleHistory(sessionPath) {
const history = loadHistory(sessionPath);
if (!history) {
console.log('(no history)');
} else {
console.log(history);
return;
}
console.log(history);
}
function handleSessions(dir) {
const sessions = listSessions(dir);
if (sessions.length === 0) {
console.log('(no sessions)');
} else {
console.log('Sessions:');
for (const s of sessions) {
console.log(' - ' + s);
}
return;
}
console.log('Sessions:');
for (const s of sessions) {
console.log(` - ${s}`);
}
}
function handleHelp() {
console.log('NanoClaw REPL Commands:');
console.log(' /clear Clear current session history');
console.log(' /history Print full conversation history');
console.log(' /sessions List all saved sessions');
console.log(' /help Show this help message');
console.log(' exit Quit the REPL');
console.log(' /help Show this help');
console.log(' /clear Clear current session history');
console.log(' /history Print full conversation history');
console.log(' /sessions List saved sessions');
console.log(' /model [name] Show/set model');
console.log(' /load <skill-name> Load a skill into active context');
console.log(' /branch <session-name> Branch current session into a new session');
console.log(' /search <query> Search query across sessions');
console.log(' /compact Keep recent turns, compact older context');
console.log(' /export <md|json|txt> [path] Export current session');
console.log(' /metrics Show session metrics');
console.log(' exit Quit the REPL');
}
// ─── Main REPL ──────────────────────────────────────────────────────────────
function main() {
const sessionName = process.env.CLAW_SESSION || 'default';
if (!isValidSessionName(sessionName)) {
console.error('Error: Invalid session name "' + sessionName + '". Use alphanumeric characters and hyphens only.');
const initialSessionName = process.env.CLAW_SESSION || 'default';
if (!isValidSessionName(initialSessionName)) {
console.error(`Error: Invalid session name "${initialSessionName}". Use alphanumeric characters and hyphens only.`);
process.exit(1);
}
const clawDir = getClawDir();
fs.mkdirSync(clawDir, { recursive: true });
fs.mkdirSync(getClawDir(), { recursive: true });
const sessionPath = getSessionPath(sessionName);
const eccContext = loadECCContext();
const state = {
sessionName: initialSessionName,
sessionPath: getSessionPath(initialSessionName),
model: DEFAULT_MODEL,
skills: normalizeSkillList(process.env.CLAW_SKILLS || ''),
};
const requestedSkills = (process.env.CLAW_SKILLS || '').split(',').map(s => s.trim()).filter(Boolean);
const loadedCount = requestedSkills.filter(name =>
fs.existsSync(path.join(process.cwd(), 'skills', name, 'SKILL.md'))
).length;
let eccContext = loadECCContext(state.skills);
console.log('NanoClaw v1.0 — Session: ' + sessionName);
const loadedCount = state.skills.filter(skillExists).length;
console.log(`NanoClaw v2 — Session: ${state.sessionName}`);
console.log(`Model: ${state.model}`);
if (loadedCount > 0) {
console.log('Loaded ' + loadedCount + ' skill(s) as context.');
console.log(`Loaded ${loadedCount} skill(s) as context.`);
}
console.log('Type /help for commands, exit to quit.\n');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = () => {
rl.question('claw> ', (input) => {
const line = input.trim();
if (!line) {
prompt();
return;
}
if (!line) return prompt();
if (line === 'exit') {
console.log('Goodbye.');
@@ -205,37 +314,123 @@ function main() {
return;
}
if (line === '/help') {
handleHelp();
return prompt();
}
if (line === '/clear') {
handleClear(sessionPath);
prompt();
return;
handleClear(state.sessionPath);
return prompt();
}
if (line === '/history') {
handleHistory(sessionPath);
prompt();
return;
handleHistory(state.sessionPath);
return prompt();
}
if (line === '/sessions') {
handleSessions();
prompt();
return;
return prompt();
}
if (line === '/help') {
handleHelp();
prompt();
return;
if (line.startsWith('/model')) {
const model = line.replace('/model', '').trim();
if (!model) {
console.log(`Current model: ${state.model}`);
} else {
state.model = model;
console.log(`Model set to: ${state.model}`);
}
return prompt();
}
// Regular message — send to Claude
const history = loadHistory(sessionPath);
appendTurn(sessionPath, 'User', line);
const response = askClaude(eccContext, history, line);
console.log('\n' + response + '\n');
appendTurn(sessionPath, 'Assistant', response);
if (line.startsWith('/load ')) {
const skill = line.replace('/load', '').trim();
if (!skill) {
console.log('Usage: /load <skill-name>');
return prompt();
}
if (!skillExists(skill)) {
console.log(`Skill not found: ${skill}`);
return prompt();
}
if (!state.skills.includes(skill)) {
state.skills.push(skill);
}
eccContext = loadECCContext(state.skills);
console.log(`Loaded skill: ${skill}`);
return prompt();
}
if (line.startsWith('/branch ')) {
const target = line.replace('/branch', '').trim();
const result = branchSession(state.sessionPath, target);
if (!result.ok) {
console.log(result.message);
return prompt();
}
state.sessionName = result.session;
state.sessionPath = result.path;
console.log(`Branched to session: ${state.sessionName}`);
return prompt();
}
if (line.startsWith('/search ')) {
const query = line.replace('/search', '').trim();
const matches = searchSessions(query);
if (matches.length === 0) {
console.log('(no matches)');
return prompt();
}
console.log(`Found ${matches.length} match(es):`);
for (const match of matches) {
console.log(`- ${match.session}: ${match.snippet}`);
}
return prompt();
}
if (line === '/compact') {
const changed = compactSession(state.sessionPath);
console.log(changed ? 'Session compacted.' : 'No compaction needed.');
return prompt();
}
if (line.startsWith('/export ')) {
const parts = line.split(/\s+/).filter(Boolean);
const format = parts[1];
const outputPath = parts[2];
if (!format) {
console.log('Usage: /export <md|json|txt> [path]');
return prompt();
}
const result = exportSession(state.sessionPath, format, outputPath);
if (!result.ok) {
console.log(result.message);
} else {
console.log(`Exported: ${result.path}`);
}
return prompt();
}
if (line === '/metrics') {
const m = getSessionMetrics(state.sessionPath);
console.log(`Session: ${state.sessionName}`);
console.log(`Model: ${state.model}`);
console.log(`Turns: ${m.turns} (user ${m.userTurns}, assistant ${m.assistantTurns})`);
console.log(`Chars: ${m.charCount}`);
console.log(`Estimated tokens: ${m.tokenEstimate}`);
return prompt();
}
// Regular message
const history = loadHistory(state.sessionPath);
appendTurn(state.sessionPath, 'User', line);
const response = askClaude(eccContext, history, line, state.model);
console.log(`\n${response}\n`);
appendTurn(state.sessionPath, 'Assistant', response);
prompt();
});
};
@@ -243,8 +438,6 @@ function main() {
prompt();
}
// ─── Exports & CLI Entry ────────────────────────────────────────────────────
module.exports = {
getClawDir,
getSessionPath,
@@ -252,14 +445,21 @@ module.exports = {
loadHistory,
appendTurn,
loadECCContext,
askClaude,
buildPrompt,
askClaude,
isValidSessionName,
handleClear,
handleHistory,
handleSessions,
handleHelp,
main
parseTurns,
estimateTokenCount,
getSessionMetrics,
searchSessions,
compactSession,
exportSession,
branchSession,
main,
};
if (require.main === module) {