UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

649 lines (641 loc) 26.1 kB
/** * RLM Agentic Tools CLI — Subcommand router for RLM support tools * * Subcommands: * chunk <file> — Split file into overlapping chunks for fanout * fanout <query> — Dispatch parallel subagent queries across chunks * rlm-prep <file|dir> — Prepare source content (chunk + index + manifest) * rlm-search <query> — Full recursive search pipeline * rlm-status — Show active task tree, progress, and cost * * These tools are designed for agentic sessions implementing the RLM pattern * (recursive decomposition + programmatic environment interaction). They are * also directly invocable by users. * * Research foundation: REF-089 (Zhang et al., 2026) * * @implements #559 */ import * as fs from 'node:fs'; import * as path from 'node:path'; /** * Resolve the effective `--parallel` / `--max-parallel` value for RLM CLI * commands, composing all applicable caps. Returns: * { effective, source, clamped, hardCapHit } * * Precedence (smallest wins): * 1. `parallelism.max_parallel_subagents` from `.aiwg/aiwg.config` (#1359) * 2. The RLM Rule 8 hard cap of 7 * 3. The user-supplied flag value (or hardcoded fallback when unset) * * When the user passes a value above the cap, we warn and clamp. When no * flag is passed, we use the resolved cap directly as the default — that is * the whole point of the project-level config. * * @implements #1360 */ export async function resolveRlmParallel(userValue, fallbackDefault, projectDir = process.cwd()) { const RLM_HARD_CAP = 7; try { const { readAiwgConfig, resolveParallelism } = await import('../config/aiwg-config.js'); const cfg = await readAiwgConfig(projectDir); if (!cfg) { // No config — fall back to user value or fallback default, still clamped to RLM hard cap const intended = userValue ?? fallbackDefault; if (intended > RLM_HARD_CAP) { return { effective: RLM_HARD_CAP, source: 'rlm-hard-cap', warning: `--parallel=${intended} clamped to ${RLM_HARD_CAP} (RLM Rule 8 hard cap)`, }; } return { effective: intended, source: userValue !== undefined ? 'user-flag' : 'fallback-default' }; } const resolved = resolveParallelism(cfg.parallelism, cfg.providers[0]); const providerCap = resolved.max_parallel_subagents; const effectiveCap = Math.min(providerCap, RLM_HARD_CAP); if (userValue === undefined) { // No flag — use the resolved cap as the default return { effective: effectiveCap, source: providerCap <= RLM_HARD_CAP ? 'provider-default' : 'rlm-hard-cap' }; } if (userValue > effectiveCap) { const reason = providerCap < RLM_HARD_CAP ? `parallelism.max_parallel_subagents=${providerCap}` : `RLM Rule 8 hard cap of ${RLM_HARD_CAP}`; return { effective: effectiveCap, source: providerCap < RLM_HARD_CAP ? 'provider-cap-clamp' : 'rlm-hard-cap', warning: `--parallel=${userValue} clamped to ${effectiveCap} (${reason})`, }; } return { effective: userValue, source: 'user-flag' }; } catch { // Config read failed — fall back to user value or fallback default const intended = userValue ?? fallbackDefault; return { effective: Math.min(intended, RLM_HARD_CAP), source: 'fallback' }; } } /** * Main CLI entry point for RLM agentic tool subcommands */ export async function main(args) { const subcommand = args[0]; const subArgs = args.slice(1); switch (subcommand) { case 'chunk': await handleChunk(subArgs); break; case 'fanout': await handleFanout(subArgs); break; case 'rlm-prep': await handleRlmPrep(subArgs); break; case 'rlm-search': await handleRlmSearch(subArgs); break; case 'rlm-status': await handleRlmStatus(subArgs); break; case 'rlm-cache': { const { main: cacheMain } = await import('./cache/cli.js'); await cacheMain(subArgs); break; } default: printUsage(); if (subcommand) { throw new Error(`Unknown RLM subcommand: ${subcommand}`); } break; } } // ============================================================ // chunk // ============================================================ async function handleChunk(args) { let file; let chunkSize = 2000; // lines per chunk let overlapArg; // resolved after chunkSize is known let format = 'json'; let outputDir; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--size': chunkSize = parseInt(args[++i], 10); break; case '--overlap': overlapArg = parseInt(args[++i], 10); break; case '--format': format = args[++i]; break; case '--output': outputDir = args[++i]; break; default: if (!args[i].startsWith('--')) file = args[i]; } } if (!file) { throw new Error('Usage: aiwg chunk <file> [--size N] [--overlap N] [--format json|text] [--output <dir>]'); } // Default overlap is 5% of chunkSize, clamped to [0, chunkSize-1] so stride is always ≥ 1. const overlap = overlapArg !== undefined ? Math.min(overlapArg, chunkSize - 1) : Math.min(Math.floor(chunkSize * 0.05), chunkSize - 1); const absoluteFile = path.resolve(file); if (!fs.existsSync(absoluteFile)) { throw new Error(`File not found: ${absoluteFile}`); } const content = fs.readFileSync(absoluteFile, 'utf-8'); const rawLines = content.split('\n'); // Remove trailing empty line produced by files ending with \n const lines = rawLines[rawLines.length - 1] === '' ? rawLines.slice(0, -1) : rawLines; const totalLines = lines.length; if (totalLines <= chunkSize) { const chunkFile = outputDir ? path.join(outputDir, 'chunk-0000.txt') : absoluteFile; const manifestFile = outputDir ? path.join(outputDir, 'manifest.json') : undefined; const chunks = [{ index: 0, start: 0, end: totalLines - 1, file: chunkFile, lines: totalLines }]; const result = { source: absoluteFile, totalLines, chunkSize, overlap: 0, chunkDir: outputDir, chunks, message: 'File fits in a single chunk, no splitting required', }; if (outputDir) { fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(chunkFile, lines.join('\n'), 'utf-8'); fs.writeFileSync(manifestFile, JSON.stringify(result, null, 2), 'utf-8'); } // File fits in one chunk — no splitting needed console.log(JSON.stringify(result, null, 2)); return; } const chunkDir = outputDir ?? path.join(path.dirname(absoluteFile), `.rlm-chunks-${path.basename(absoluteFile)}`); fs.mkdirSync(chunkDir, { recursive: true }); const chunks = []; let chunkIndex = 0; let start = 0; while (start < totalLines) { const end = Math.min(start + chunkSize - 1, totalLines - 1); const chunkLines = lines.slice(start, end + 1); const chunkFile = path.join(chunkDir, `chunk-${String(chunkIndex).padStart(4, '0')}.txt`); fs.writeFileSync(chunkFile, chunkLines.join('\n'), 'utf-8'); chunks.push({ index: chunkIndex, start, end, file: chunkFile, lines: chunkLines.length }); chunkIndex++; // Advance with overlap: next chunk starts (chunkSize - overlap) lines ahead. // Clamp stride to at least 1 to prevent infinite loop when overlap >= chunkSize. const stride = Math.max(1, chunkSize - overlap); start += stride; if (start >= totalLines) break; } const manifest = { source: absoluteFile, totalLines, chunkSize, overlap, chunkDir, chunks, createdAt: new Date().toISOString(), }; const manifestFile = path.join(chunkDir, 'manifest.json'); fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2), 'utf-8'); if (format === 'json') { console.log(JSON.stringify(manifest, null, 2)); } else { console.log(`Chunked ${absoluteFile} into ${chunks.length} chunks`); console.log(`Chunk directory: ${chunkDir}`); console.log(`Manifest: ${manifestFile}`); for (const chunk of chunks) { console.log(` chunk-${String(chunk.index).padStart(4, '0')}: lines ${chunk.start}-${chunk.end} (${chunk.lines} lines)`); } } } // ============================================================ // fanout // ============================================================ async function handleFanout(args) { let query; let chunksPath; let userParallel; let model = 'sonnet'; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--chunks': chunksPath = args[++i]; break; case '--parallel': case '--max-parallel': userParallel = parseInt(args[++i], 10); break; case '--model': model = args[++i]; break; default: if (!args[i].startsWith('--')) query = args[i]; } } if (!query || !chunksPath) { throw new Error('Usage: aiwg fanout <query> --chunks <dir|manifest.json> [--parallel N] [--model haiku|sonnet|opus]'); } // #1360: Resolve --parallel against aiwg.config parallelism cap (warn + clamp). const resolved = await resolveRlmParallel(userParallel, 5); const parallel = resolved.effective; if (resolved.warning) console.warn(`⚠ ${resolved.warning}`); // Resolve manifest let manifestFile; const absoluteChunks = path.resolve(chunksPath); if (!fs.existsSync(absoluteChunks)) { throw new Error(`Manifest not found: ${absoluteChunks}. Run 'aiwg chunk' first.`); } if (fs.statSync(absoluteChunks).isDirectory()) { manifestFile = path.join(absoluteChunks, 'manifest.json'); } else { manifestFile = absoluteChunks; } if (!fs.existsSync(manifestFile)) { throw new Error(`Manifest not found: ${manifestFile}. Run 'aiwg chunk' first.`); } const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')); const chunks = manifest.chunks; console.log(`Fanout: "${query}" across ${chunks.length} chunks`); console.log(`Model: ${model}, Max parallel: ${parallel}`); console.log(''); console.log('NOTE: This command produces a dispatch plan for the RLM agent to execute.'); console.log('The RLM agent spawns subagents for each chunk using the Task tool.'); console.log(''); console.log('Dispatch plan:'); // Output structured dispatch plan for the RLM agent const dispatchPlan = { query, model, maxParallel: parallel, totalChunks: chunks.length, waves: [], }; for (let i = 0; i < chunks.length; i += parallel) { const wave = chunks.slice(i, i + parallel); dispatchPlan.waves.push({ wave: Math.floor(i / parallel), chunks: wave }); } console.log(JSON.stringify(dispatchPlan, null, 2)); } // ============================================================ // rlm-prep // ============================================================ async function handleRlmPrep(args) { let source; let outputDir; let strategy = 'adaptive'; let chunkSize = 2000; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--output': outputDir = args[++i]; break; case '--strategy': strategy = args[++i]; break; case '--size': chunkSize = parseInt(args[++i], 10); break; default: if (!args[i].startsWith('--')) source = args[i]; } } if (!source) { throw new Error('Usage: aiwg rlm-prep <file|dir> [--output <dir>] [--strategy semantic-boundary|fixed-count|adaptive] [--size N]'); } const absoluteSource = path.resolve(source); if (!fs.existsSync(absoluteSource)) { throw new Error(`Source not found: ${absoluteSource}`); } const prepDir = outputDir ?? path.join(process.cwd(), '.rlm-prep'); fs.mkdirSync(prepDir, { recursive: true }); const stat = fs.statSync(absoluteSource); const files = []; if (stat.isFile()) { files.push(absoluteSource); } else { // Recursively collect text files from directory collectFiles(absoluteSource, files); } console.log(`RLM Prep: ${files.length} file(s) from ${absoluteSource}`); console.log(`Strategy: ${strategy}, Chunk size: ${chunkSize} lines`); console.log(`Output: ${prepDir}`); console.log(''); const index = { source: absoluteSource, prepDir, strategy, chunkSize, files: [], createdAt: new Date().toISOString(), }; for (const file of files) { const relRoot = stat.isFile() ? path.dirname(absoluteSource) : absoluteSource; const relPath = path.relative(relRoot, file); const fileOutputDir = path.join(prepDir, relPath + '.chunks'); fs.mkdirSync(fileOutputDir, { recursive: true }); // Chunk this file await handleChunk([file, '--size', String(chunkSize), '--output', fileOutputDir, '--format', 'json']); const manifestFile = path.join(fileOutputDir, 'manifest.json'); if (fs.existsSync(manifestFile)) { const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')); index.files.push({ path: file, manifest: manifestFile, chunks: manifest.chunks.length }); console.log(` ✓ ${relPath || path.basename(file)}: ${manifest.chunks.length} chunk(s)`); } } const indexFile = path.join(prepDir, 'index.json'); fs.writeFileSync(indexFile, JSON.stringify(index, null, 2), 'utf-8'); console.log(''); console.log(`Index written: ${indexFile}`); console.log(`Total files: ${index.files.length}`); console.log(`Total chunks: ${index.files.reduce((sum, f) => sum + f.chunks, 0)}`); } function collectFiles(dir, results) { const SKIP_DIRS = new Set(['.git', 'node_modules', '.rlm-prep', '.rlm-chunks']); const TEXT_EXTENSIONS = new Set(['.ts', '.js', '.mjs', '.cjs', '.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.sh']); for (const entry of fs.readdirSync(dir)) { if (SKIP_DIRS.has(entry)) continue; const full = path.join(dir, entry); const stat = fs.statSync(full); if (stat.isDirectory()) { collectFiles(full, results); } else if (TEXT_EXTENSIONS.has(path.extname(entry).toLowerCase())) { results.push(full); } } } // ============================================================ // rlm-search // ============================================================ async function handleRlmSearch(args) { let query; let sourceArg; let depth = 3; let userParallel; let budget = 500000; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--source': sourceArg = args[++i]; break; case '--depth': depth = parseInt(args[++i], 10); break; case '--parallel': case '--max-parallel': userParallel = parseInt(args[++i], 10); break; case '--budget': budget = parseInt(args[++i], 10); break; case '--help': case '-h': throw new Error('Usage: aiwg rlm-search <query> --source <file|dir> [--depth N] [--parallel N|--max-parallel N] [--budget N]'); default: if (args[i].startsWith('--')) { throw new Error(`Unknown rlm-search option: ${args[i]}`); } if (!query) query = args[i]; } } if (!query || !sourceArg) { throw new Error('Usage: aiwg rlm-search <query> --source <file|dir> [--depth N] [--parallel N|--max-parallel N] [--budget N]'); } // #1360: Resolve --parallel against aiwg.config parallelism cap (warn + clamp). const resolvedParallel = await resolveRlmParallel(userParallel, 5); const parallel = resolvedParallel.effective; if (resolvedParallel.warning) console.warn(`⚠ ${resolvedParallel.warning}`); const absoluteSource = path.resolve(sourceArg); const prepDir = path.join(process.cwd(), '.rlm-prep'); const indexFile = path.join(prepDir, 'index.json'); console.log(`RLM Search: "${query}"`); console.log(`Source: ${absoluteSource}`); console.log(`Max depth: ${depth}, Max parallel: ${parallel}, Token budget: ${budget}`); console.log(''); // Check if source is already prepped for this source. Older prep indexes may // have omitted single-chunk files, so validate file coverage before reuse. if (!isPrepIndexUsable(indexFile, absoluteSource)) { console.log('Source not prepped — running rlm-prep first...'); await handleRlmPrep(['--output', prepDir, absoluteSource]); console.log(''); } const index = JSON.parse(fs.readFileSync(indexFile, 'utf-8')); console.log(`Search plan (${index.files.length} file(s), ${index.files.reduce((s, f) => s + f.chunks, 0)} chunks total):`); console.log(''); console.log('Phase 1 — Fan out query across all chunks'); console.log(` Dispatch ${Math.ceil(index.files.reduce((s, f) => s + f.chunks, 0) / parallel)} wave(s) of up to ${parallel} parallel subagents`); console.log(' Each subagent: grep chunk for query, extract relevant passages'); console.log(''); console.log('Phase 2 — Synthesize results'); console.log(' Collect all relevant passages from Phase 1'); console.log(' If synthesis exceeds context: chunk passages and recurse (Phase 1 again)'); console.log(' When fits in one context: produce final answer'); console.log(''); console.log('Execution plan (JSON for RLM agent):'); const executionPlan = { query, source: absoluteSource, indexFile, maxDepth: depth, maxParallel: parallel, tokenBudget: budget, phases: [ { phase: 1, name: 'fanout', files: index.files.map((f) => ({ path: f.path, manifest: f.manifest, chunks: f.chunks, })), }, { phase: 2, name: 'synthesize', description: 'Collect Phase 1 results, synthesize answer, recurse if needed', }, ], estimatedSubAgents: index.files.reduce((s, f) => s + f.chunks, 0), }; console.log(JSON.stringify(executionPlan, null, 2)); } function isPrepIndexUsable(indexFile, absoluteSource) { if (!fs.existsSync(indexFile)) return false; try { const index = JSON.parse(fs.readFileSync(indexFile, 'utf-8')); if (index.source !== absoluteSource) return false; if (!Array.isArray(index.files)) return false; const stat = fs.statSync(absoluteSource); const expectedFiles = []; if (stat.isFile()) { expectedFiles.push(absoluteSource); } else { collectFiles(absoluteSource, expectedFiles); } if (index.files.length !== expectedFiles.length) return false; const indexed = new Set(index.files.map((f) => f.path)); for (const file of expectedFiles) { if (!indexed.has(file)) return false; } for (const entry of index.files) { if (!fs.existsSync(entry.manifest)) return false; const manifest = JSON.parse(fs.readFileSync(entry.manifest, 'utf-8')); if (!Array.isArray(manifest.chunks) || manifest.chunks.length !== entry.chunks) return false; if (manifest.chunks.some((chunk) => !chunk.file || !fs.existsSync(chunk.file))) return false; } return true; } catch { return false; } } // ============================================================ // rlm-status // ============================================================ async function handleRlmStatus(args) { const showCost = args.includes('--cost'); const showTree = args.includes('--tree'); const asJson = args.includes('--json'); let taskId; const taskIdIndex = args.indexOf('--task-id'); if (taskIdIndex !== -1) taskId = args[taskIdIndex + 1]; // Look for RLM state files const statePaths = [ path.join(process.cwd(), '.aiwg', 'ralph', 'rlm-state.json'), path.join(process.cwd(), '.rlm-prep', 'index.json'), ]; const stateFile = statePaths.find((p) => fs.existsSync(p)); if (!stateFile) { const status = { status: 'idle', message: 'No active RLM task found. Start a task with `aiwg rlm-search` or `/rlm-query`.', checkedPaths: statePaths, }; if (asJson) { console.log(JSON.stringify(status, null, 2)); } else { console.log('RLM Status: Idle'); console.log('No active RLM task found.'); console.log('Start a task with `aiwg rlm-search` or `/rlm-query`.'); } return; } const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); if (asJson) { console.log(JSON.stringify({ stateFile, taskId, state }, null, 2)); return; } console.log('RLM Status'); console.log('══════════'); console.log(`State file: ${stateFile}`); if (state.source) { console.log(`Source: ${state.source}`); } if (state.createdAt) { console.log(`Created: ${state.createdAt}`); } if (state.files) { console.log(`Files indexed: ${state.files.length}`); const totalChunks = state.files.reduce((s, f) => s + f.chunks, 0); console.log(`Total chunks: ${totalChunks}`); } if (showCost) { const costFile = path.join(process.cwd(), '.aiwg', 'ralph', 'rlm-cost.json'); if (fs.existsSync(costFile)) { const cost = JSON.parse(fs.readFileSync(costFile, 'utf-8')); console.log(''); console.log('Cost breakdown:'); console.log(JSON.stringify(cost, null, 2)); } else { console.log(''); console.log('Cost tracking: No cost data available yet.'); } } if (showTree && state.taskTree) { console.log(''); console.log('Task tree:'); printTaskTree(state.taskTree, 0); } } function printTaskTree(node, depth) { const indent = ' '.repeat(depth); const status = node.status ?? 'unknown'; const icon = status === 'complete' ? '✓' : status === 'running' ? '⏳' : status === 'failed' ? '✗' : '○'; console.log(`${indent}${icon} ${node.id ?? 'root'} [${status}]`); if (Array.isArray(node.children)) { for (const child of node.children) { printTaskTree(child, depth + 1); } } } // ============================================================ // Usage // ============================================================ function printUsage() { console.log(`AIWG Agentic Tools — Support tools for RLM sessions Usage: aiwg <tool> [options] Tools: chunk <file> Split a file into overlapping chunks for fanout fanout <query> Dispatch parallel subagent queries across chunks rlm-prep <file|dir> Prepare source content (chunk + index + manifest) rlm-search <query> Full recursive search pipeline rlm-status Show active RLM task tree, progress, and cost chunk options: --size N Lines per chunk (default: 2000) --overlap N Overlap lines between chunks (default: 100) --format json|text Output format (default: json) --output <dir> Output directory for chunks fanout options: --chunks <dir|manifest> Chunk directory or manifest.json (required) --parallel N Max parallel subagents (default: 5) --model haiku|sonnet|opus Model tier for subagents (default: sonnet) rlm-prep options: --output <dir> Output directory (default: .rlm-prep) --strategy <s> semantic-boundary | fixed-count | adaptive (default: adaptive) --size N Lines per chunk (default: 2000) rlm-search options: --source <file|dir> Source to search (required) --depth N Max recursion depth (default: 3) --parallel N Max parallel subagents per wave (default: 5) --max-parallel N Alias for --parallel --budget N Token budget (default: 500000) rlm-status options: --cost Show cost breakdown --tree Show task tree --json Output as JSON --task-id <id> Show specific task Research foundation: REF-089 (Zhang et al., 2026) Documentation: agentic/code/addons/rlm/README.md`); } //# sourceMappingURL=cli.js.map