diff --git a/README.md b/README.md index b3c8f77..2a35b1a 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,19 @@ Based on the viral observation that caveman-speak dramatically reduces LLM token ## Install -Pick your agent. One command. Done. +**One line, every agent on your machine:** + +```bash +# macOS / Linux / WSL / Git Bash +curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash + +# Windows (PowerShell) +irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex +``` + +Installer detect every agent on machine (Claude Code, Gemini CLI, Codex, Cursor, Windsurf, Cline, Copilot). Run native install for each. Skip what you not have. Safe to re-run. Pass `--only ` for one target, `--dry-run` to preview. + +**Or pick your agent manually:** | Agent | Install | |-------|---------| @@ -160,6 +172,7 @@ Auto-activation is built in for Claude Code, Gemini CLI, and the repo-local Code | caveman-compress | Y | Y | Y | Y | Y | Y | Y | | caveman-help | Y | — | Y | Y | Y | Y | Y | | caveman-stats | Y | — | — | — | — | — | — | +| cavecrew (subagents) | Y | — | — | — | — | — | — | > [!NOTE] > Auto-activation works differently per agent: Claude Code uses SessionStart hooks, this repo's Codex dogfood setup uses `.codex/hooks.json`, Gemini uses context files. Cursor/Windsurf/Cline/Copilot can be made always-on, but `npx skills add` installs only the skill, not the repo rule/instruction files. @@ -375,7 +388,7 @@ Level stick until you change it or session end. ### caveman-stats -`/caveman-stats` — real token usage for current session + estimated savings. Reads the Claude Code session JSONL directly so the numbers are not the model's guess. Savings estimate uses the 65% mean per-task figure from `benchmarks/`; only `full` mode has measured data. +`/caveman-stats` — real token usage + estimated savings + dollar amount. Reads the Claude Code session JSONL directly so the numbers are not the model's guess. Pricing comes from the model id stamped on each turn; ratio comes from `benchmarks/` (only `full` has measured data so far). ``` Caveman Stats @@ -388,11 +401,58 @@ Cache-read tokens: 128,400 ────────────────────────────────── Est. without caveman: 9,171 Est. tokens saved: 5,961 (~65%) -Savings est. from benchmarks/ (mean per-task). Actual varies by task. +Est. saved (USD): ~$0.089 +────────────────────────────────── +Memory compressed: 2 files, ~1,920 tokens saved per session start (approx) +Savings est. from benchmarks/ (mean per-task). Pricing for claude-sonnet-4-7. Actual varies by task. ``` +**Lifetime totals** — caveman-stats appends a snapshot to `~/.claude/.caveman-history.jsonl` on every run: + +| Command | What | +|---|---| +| `/caveman-stats` | This session only | +| `/caveman-stats --all` | Everything ever recorded (one row per session, latest snapshot wins) | +| `/caveman-stats --since 7d` | Last 7 days (`Nh` or `Nd`) | +| `/caveman-stats --share` | One-line tweetable summary 🪨 | + +**Statusline savings** — opt-in. Set `CAVEMAN_STATUSLINE_SAVINGS=1` in your shell environment, then your status bar shows `[CAVEMAN] ⛏ 12.4k` (lifetime tokens saved). Updates every time `/caveman-stats` runs. + Claude Code only — needs the hook system to read the session transcript. +### cavecrew + +Caveman-flavored subagent presets for Claude Code. Three drop-ins: + +- `@cavecrew-investigator` — read-only research. Returns `file:line` references in fragment form. No suggestions. +- `@cavecrew-builder` — small surgical edits in 1-2 files. Returns a caveman-style diff summary. +- `@cavecrew-reviewer` — PR review in `L: . .` form. No praise, no scope creep. + +When you delegate work to a subagent, agent-to-agent prose is exactly the spot where caveman's grammar earns the most. All three subagents inherit caveman rules at ultra intensity, so handoffs stay terse without you having to remind them every turn. + +Claude Code only — subagents are a Claude Code primitive. + +### caveman-init (cavepack) + +Drop the always-on caveman rule into any repo, for every IDE agent at once. Idempotent. + +```bash +# In your project root: +node tools/caveman-init.js # writes rule files for all targets +node tools/caveman-init.js --dry-run # preview what would change +node tools/caveman-init.js --only cline # one target only +``` + +Targets installed (skips any that already contain the caveman sentinel): + +- `.cursor/rules/caveman.mdc` — Cursor frontmatter (`alwaysApply: true`) +- `.windsurf/rules/caveman.md` — Windsurf frontmatter (`trigger: always_on`) +- `.clinerules/caveman.md` — Cline (auto-discovered) +- `.github/copilot-instructions.md` — Copilot (appended below existing content) +- `AGENTS.md` — generic agent context (appended) + +Existing rule files are left alone unless `--force` is passed; appendable targets (Copilot, AGENTS.md) get the caveman block appended below your existing content. To compress an existing `CLAUDE.md`, use `/caveman:compress` instead — that's a separate, higher-stakes operation. + ### caveman-compress `/caveman:compress ` — caveman make Claude *speak* with fewer tokens. **Compress** make Claude *read* fewer tokens. @@ -419,6 +479,28 @@ CLAUDE.original.md ← human-readable backup (you read and edit this) Code blocks, URLs, file paths, commands, headings, dates, version numbers — anything technical passes through untouched. Only prose gets compressed. See the full [caveman-compress README](caveman-compress/README.md) for details. [Security note](./caveman-compress/SECURITY.md): Snyk flags this as High Risk due to subprocess/file patterns — it's a false positive. +## caveman-shrink (MCP middleware) + +Wrap any MCP server. Cut the prose. Keep the substance. + +```jsonc +{ + "mcpServers": { + "fs-shrunk": { + "command": "npx", + "args": [ + "caveman-shrink", + "npx", "@modelcontextprotocol/server-filesystem", "/path/to/dir" + ] + } + } +} +``` + +`caveman-shrink` is a stdio proxy. It spawns the upstream MCP server, intercepts `tools/list` / `prompts/list` / `resources/list` responses, and runs caveman compression over the `description` fields (and anything else you list in `CAVEMAN_SHRINK_FIELDS`). Code, URLs, paths, and identifiers stay byte-for-byte identical — same boundaries as the parent skill. + +What it does NOT touch in v1: tool-call response bodies, request bodies, or any non-prose data. See [`mcp-servers/caveman-shrink/`](mcp-servers/caveman-shrink) for full docs. + ## Benchmarks Real token counts from the Claude API ([reproduce it yourself](benchmarks/)): diff --git a/commands/caveman-init.toml b/commands/caveman-init.toml new file mode 100644 index 0000000..df07570 --- /dev/null +++ b/commands/caveman-init.toml @@ -0,0 +1,4 @@ +--- +description = "Drop the always-on caveman activation rule into the current repo for every IDE agent" +prompt = "Run `node tools/caveman-init.js {{args}}` in the current repo and report the result. Use --dry-run first if the user did not pass --force, so we never silently overwrite an existing rule file." +--- diff --git a/hooks/caveman-config.js b/hooks/caveman-config.js index 9ea2363..d573f95 100644 --- a/hooks/caveman-config.js +++ b/hooks/caveman-config.js @@ -189,4 +189,86 @@ function readFlag(flagPath) { } } -module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag, readFlag }; +// Symlink-safe append. Same parent-dir + symlink-target rules as safeWriteFlag, +// but opens with O_APPEND so concurrent writers from different sessions don't +// clobber each other. Used for the lifetime stats log +// ($CLAUDE_CONFIG_DIR/.caveman-history.jsonl). +// +// Silent-fails on any filesystem error. +function appendFlag(filePath, line) { + const debug = process.env.CAVEMAN_DEBUG === '1'; + try { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + let realDir; + try { + const lstat = fs.lstatSync(dir); + if (lstat.isSymbolicLink()) { + realDir = fs.realpathSync(dir); + const realStat = fs.statSync(realDir); + if (!realStat.isDirectory()) return; + if (typeof process.getuid === 'function') { + if (realStat.uid !== process.getuid()) { + if (debug) process.stderr.write(`[caveman] appendFlag: symlink target ${realDir} owned by uid ${realStat.uid}\n`); + return; + } + } else { + const home = os.homedir(); + const normalized = path.resolve(realDir).toLowerCase(); + const normalizedHome = path.resolve(home).toLowerCase(); + if (!normalized.startsWith(normalizedHome + path.sep) && normalized !== normalizedHome) return; + } + } else { + realDir = dir; + } + } catch (e) { + return; + } + + const realPath = path.join(realDir, path.basename(filePath)); + try { + if (fs.lstatSync(realPath).isSymbolicLink()) return; + } catch (e) { + if (e.code !== 'ENOENT') return; + } + + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | O_NOFOLLOW; + let fd; + try { + fd = fs.openSync(realPath, flags, 0o600); + fs.writeSync(fd, String(line).replace(/\n$/, '') + '\n'); + try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + } catch (e) { + // Silent fail — history is best-effort + } +} + +// Symlink-safe history read. Returns lines (untrimmed) or empty array on any +// anomaly. Caller is responsible for parsing JSON. Does NOT enforce a size cap +// the way readFlag does — history is expected to grow with use. +function readHistory(filePath) { + try { + const st = fs.lstatSync(filePath); + if (st.isSymbolicLink() || !st.isFile()) return []; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const flags = fs.constants.O_RDONLY | O_NOFOLLOW; + let fd; + let raw; + try { + fd = fs.openSync(filePath, flags); + raw = fs.readFileSync(fd, 'utf8'); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } + return raw.split('\n').filter(line => line.trim()); + } catch (e) { + return []; + } +} + +module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory }; diff --git a/hooks/caveman-mode-tracker.js b/hooks/caveman-mode-tracker.js index 4dbeb62..41bcb04 100644 --- a/hooks/caveman-mode-tracker.js +++ b/hooks/caveman-mode-tracker.js @@ -35,14 +35,22 @@ process.stdin.on('end', () => { } } - // /caveman-stats — block the prompt and inject stats output as the - // hook's reason. The script reads the active session log, so we pass + // /caveman-stats [--share] — block the prompt and inject stats output as + // the hook's reason. The script reads the active session log, so we pass // transcript_path through when Claude Code provides it. - if (prompt === '/caveman-stats' || prompt === '/caveman:caveman-stats') { + const statsMatch = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/.exec(prompt); + if (statsMatch) { + const tailArgs = (statsMatch[1] || '').trim().split(/\s+/).filter(Boolean); try { const statsPath = path.join(__dirname, 'caveman-stats.js'); const argv = [statsPath]; if (data.transcript_path) argv.push('--session-file', data.transcript_path); + if (tailArgs.includes('--share')) argv.push('--share'); + if (tailArgs.includes('--all')) argv.push('--all'); + const sinceIdx = tailArgs.indexOf('--since'); + if (sinceIdx !== -1 && tailArgs[sinceIdx + 1]) { + argv.push('--since', tailArgs[sinceIdx + 1]); + } const out = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 }); process.stdout.write(JSON.stringify({ decision: 'block', reason: out.trim() })); } catch (e) { diff --git a/hooks/caveman-stats.js b/hooks/caveman-stats.js index 7cdb383..8d2c537 100644 --- a/hooks/caveman-stats.js +++ b/hooks/caveman-stats.js @@ -10,13 +10,41 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { readFlag } = require('./caveman-config'); +const { readFlag, appendFlag, readHistory } = require('./caveman-config'); // Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across -// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite/ultra/ -// wenyan modes show no estimate. +// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra / +// wenyan modes show no estimate until benchmarked. Add an entry here when a new +// run is committed. const COMPRESSION = { 'full': 0.65 }; +// Approximate Anthropic public output-token pricing, USD per million. +// Match by model id prefix so this stays correct across point releases +// (e.g. claude-sonnet-4-20250514, claude-sonnet-4-7). Update from +// https://www.anthropic.com/pricing if a release changes the tier. +const MODEL_OUTPUT_PRICE_PER_M = [ + ['claude-opus-4', 75.00], + ['claude-sonnet-4', 15.00], + ['claude-haiku-4', 4.00], + ['claude-3-5-sonnet', 15.00], + ['claude-3-5-haiku', 4.00], + ['claude-3-opus', 75.00], +]; + +function priceForModel(model) { + if (!model) return null; + for (const [prefix, price] of MODEL_OUTPUT_PRICE_PER_M) { + if (model.startsWith(prefix)) return price; + } + return null; +} + +function formatUsd(amount) { + if (amount >= 1) return `$${amount.toFixed(2)}`; + if (amount >= 0.01) return `$${amount.toFixed(3)}`; + return `$${amount.toFixed(4)}`; +} + function findRecentSession(claudeDir) { const projectsDir = path.join(claudeDir, 'projects'); let entries; @@ -43,30 +71,228 @@ function findRecentSession(claudeDir) { function parseSession(filePath) { let raw; try { raw = fs.readFileSync(filePath, 'utf8'); } - catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0 }; } + catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0, model: null }; } let outputTokens = 0; let cacheReadTokens = 0; let turns = 0; + let model = null; for (const line of raw.split('\n')) { if (!line.trim()) continue; let entry; try { entry = JSON.parse(line); } catch { continue; } - const usage = entry.type === 'assistant' && entry.message && entry.message.usage; + if (entry.type !== 'assistant' || !entry.message) continue; + const usage = entry.message.usage; if (!usage) continue; outputTokens += usage.output_tokens || 0; cacheReadTokens += usage.cache_read_input_tokens || 0; turns++; + if (!model && entry.message.model) model = entry.message.model; } - return { outputTokens, cacheReadTokens, turns }; + return { outputTokens, cacheReadTokens, turns, model }; +} + +// Detect *.original.md / *.md pairs left behind by caveman-compress. The +// presence of a *.original.md backup means the *.md sibling is a compressed +// memory file — every session start reads the compressed version, so the +// delta is per-session input-token savings (passive). Returns a summary or +// null if nothing was found in the given dirs. +function findCompressedPairs(dirs) { + const pairs = []; + for (const dir of dirs) { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { continue; } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.original.md')) continue; + const base = entry.name.slice(0, -'.original.md'.length); + const originalPath = path.join(dir, entry.name); + const compressedPath = path.join(dir, `${base}.md`); + let oSize, cSize; + try { + oSize = fs.statSync(originalPath).size; + cSize = fs.statSync(compressedPath).size; + } catch { continue; } + if (oSize <= cSize) continue; + pairs.push({ name: base, dir, originalSize: oSize, compressedSize: cSize }); + } + } + return pairs; +} + +function summarizeCompressed(pairs) { + if (!pairs || pairs.length === 0) return null; + const totalOriginal = pairs.reduce((s, p) => s + p.originalSize, 0); + const totalCompressed = pairs.reduce((s, p) => s + p.compressedSize, 0); + const bytesSaved = totalOriginal - totalCompressed; + // English prose runs ~4 chars per token. Label result as approximate so we + // don't make claims tighter than the method warrants. + const tokensSaved = Math.round(bytesSaved / 4); + return { count: pairs.length, bytesSaved, tokensSaved }; +} + +// Compute the savings figures we want to log/share for one session snapshot. +function deriveSavings({ outputTokens, mode, model }) { + const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null; + const price = priceForModel(model); + if (ratio === null) return { estSavedTokens: 0, estSavedUsd: 0 }; + const estNormal = Math.round(outputTokens / (1 - ratio)); + const estSavedTokens = estNormal - outputTokens; + const estSavedUsd = price !== null ? (estSavedTokens / 1_000_000) * price : 0; + return { estSavedTokens, estSavedUsd }; +} + +// Parse "7d", "12h" etc. to milliseconds. Returns null on invalid input. +function parseDuration(spec) { + if (!spec) return null; + const m = /^(\d+)([dh])$/.exec(spec.trim()); + if (!m) return null; + const n = parseInt(m[1], 10); + return m[2] === 'd' ? n * 86_400_000 : n * 3_600_000; +} + +// Aggregate history into latest-per-session totals, optionally filtered to a +// time window. Returns { sessions, outputTokens, estSavedTokens, estSavedUsd }. +function aggregateHistory(historyPath, sinceMs) { + const lines = readHistory(historyPath); + const cutoff = sinceMs ? Date.now() - sinceMs : null; + const latestPerSession = new Map(); + for (const line of lines) { + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (!entry || typeof entry !== 'object') continue; + if (cutoff !== null && (entry.ts || 0) < cutoff) continue; + const id = entry.session_id || '_'; + const prev = latestPerSession.get(id); + if (!prev || (entry.ts || 0) >= (prev.ts || 0)) latestPerSession.set(id, entry); + } + let outputTokens = 0, estSavedTokens = 0, estSavedUsd = 0; + for (const e of latestPerSession.values()) { + outputTokens += e.output_tokens || 0; + estSavedTokens += e.est_saved_tokens || 0; + estSavedUsd += e.est_saved_usd || 0; + } + return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd }; +} + +function humanizeTokens(n) { + if (!Number.isFinite(n) || n <= 0) return '0'; + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(Math.round(n)); +} + +function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, since }) { + const sep = '──────────────────────────────────'; + const window = since ? ` (last ${since})` : ''; + if (sessions === 0) { + return `\nCaveman Stats — Lifetime${window}\n${sep}\nNo sessions logged yet — run /caveman-stats inside any session to start tracking.\n${sep}\n`; + } + const usdLine = estSavedUsd > 0 ? `Est. saved (USD): ~${formatUsd(estSavedUsd)}\n` : ''; + return `\nCaveman Stats — Lifetime${window}\n${sep}\n` + + `Sessions: ${sessions.toLocaleString()}\n${sep}\n` + + `Output tokens: ${outputTokens.toLocaleString()}\n` + + `Est. tokens saved: ${estSavedTokens.toLocaleString()}\n` + + usdLine + sep + '\n'; +} + +// Single-line tweetable summary. Stays human-friendly when no ratio is known. +function formatShare({ outputTokens, turns, mode, model }) { + if (turns === 0) { + return '🪨 caveman armed but no turns yet — caveman.sh'; + } + const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null; + const price = priceForModel(model); + + if (ratio !== null) { + const estSaved = Math.round(outputTokens / (1 - ratio)) - outputTokens; + let usd = ''; + if (price !== null) { + const amt = (estSaved / 1_000_000) * price; + usd = ` (~${formatUsd(amt)})`; + } + return `🪨 Saved ${estSaved.toLocaleString()} output tokens${usd} across ${turns} turns this session — caveman.sh`; + } + return `🪨 ${turns} turns, ${outputTokens.toLocaleString()} output tokens this session — caveman.sh`; +} + +// Pure formatter — separated from main() so tests can pass synthetic inputs. +function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessionPath, compressed }) { + const sep = '──────────────────────────────────'; + const shortPath = sessionPath && sessionPath.length > 45 + ? '...' + sessionPath.slice(-45) + : (sessionPath || ''); + + if (turns === 0) { + return `\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`; + } + + const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null; + const price = priceForModel(model); + + let savings; + let footer = ''; + if (ratio !== null) { + const estNormal = Math.round(outputTokens / (1 - ratio)); + const estSaved = estNormal - outputTokens; + let usdLine = ''; + if (price !== null) { + const usd = (estSaved / 1_000_000) * price; + usdLine = `Est. saved (USD): ~${formatUsd(usd)}\n`; + footer = `Savings est. from benchmarks/ (mean per-task). Pricing for ${model}. Actual varies by task.`; + } else { + footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.'; + } + savings = `Est. without caveman: ${estNormal.toLocaleString()}\n` + + `Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}%)\n` + + usdLine.replace(/\n$/, ''); + } else if (mode && mode !== 'off') { + savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`; + } else { + savings = 'Caveman not active this session.'; + } + + let memoryLine = ''; + if (compressed && compressed.count > 0) { + const tokensApprox = compressed.tokensSaved.toLocaleString(); + memoryLine = `${sep}\nMemory compressed: ${compressed.count} file${compressed.count === 1 ? '' : 's'}, ` + + `~${tokensApprox} tokens saved per session start (approx)\n`; + } + + return `\nCaveman Stats\n${sep}\n` + + (shortPath ? `Session: ${shortPath}\n` : '') + + `Turns: ${turns}\n${sep}\n` + + `Output tokens: ${outputTokens.toLocaleString()}\n` + + `Cache-read tokens: ${cacheReadTokens.toLocaleString()}\n${sep}\n` + + `${savings}\n` + + memoryLine + + (footer ? footer + '\n' : ''); } function main() { const args = process.argv.slice(2); const i = args.indexOf('--session-file'); const sessionFileArg = i !== -1 ? args[i + 1] : null; + const share = args.includes('--share'); + const all = args.includes('--all'); + const sinceIdx = args.indexOf('--since'); + const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null; const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const historyPath = path.join(claudeDir, '.caveman-history.jsonl'); + + // Lifetime aggregation paths short-circuit before we need a live session. + if (all || sinceArg) { + const sinceMs = parseDuration(sinceArg); + if (sinceArg && sinceMs === null) { + process.stderr.write(`caveman-stats: --since takes Nh or Nd (e.g. 7d, 24h), got: ${sinceArg}\n`); + process.exit(2); + } + const agg = aggregateHistory(historyPath, sinceMs); + process.stdout.write(formatHistory({ ...agg, since: sinceArg || null })); + return; + } + const sessionFile = sessionFileArg || findRecentSession(claudeDir); if (!sessionFile) { @@ -74,40 +300,49 @@ function main() { process.exit(1); } - const { outputTokens, cacheReadTokens, turns } = parseSession(sessionFile); + const parsed = parseSession(sessionFile); const mode = readFlag(path.join(claudeDir, '.caveman-active')); - const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null; - const sep = '──────────────────────────────────'; - const shortPath = sessionFile.length > 45 ? '...' + sessionFile.slice(-45) : sessionFile; - if (turns === 0) { - process.stdout.write(`\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`); - return; + // Append a snapshot of this session's totals to the lifetime log. Multiple + // /caveman-stats calls in one session emit multiple lines for the same + // session_id; aggregateHistory keeps only the latest per session_id. + if (parsed.turns > 0) { + const { estSavedTokens, estSavedUsd } = deriveSavings({ ...parsed, mode }); + const sessionId = path.basename(sessionFile, '.jsonl'); + appendFlag(historyPath, JSON.stringify({ + ts: Date.now(), + session_id: sessionId, + mode: mode || null, + model: parsed.model || null, + output_tokens: parsed.outputTokens, + est_saved_tokens: estSavedTokens, + est_saved_usd: estSavedUsd, + })); + + // Statusline suffix: tiny pre-rendered string the shell statusline can + // cat without parsing JSONL. Updated on every /caveman-stats run. + try { + const agg = aggregateHistory(historyPath, null); + const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : ''; + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), suffix, { mode: 0o600 }); + } catch (e) { + // Best-effort; the badge still renders without it. + } } - let savings; - let footer = ''; - if (ratio !== null) { - const estNormal = Math.round(outputTokens / (1 - ratio)); - const estSaved = estNormal - outputTokens; - savings = `Est. without caveman: ${estNormal.toLocaleString()}\n` + - `Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}%)`; - footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.'; - } else if (mode && mode !== 'off') { - savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`; + if (share) { + process.stdout.write(formatShare({ ...parsed, mode }) + '\n'); } else { - savings = 'Caveman not active this session.'; + const scanDirs = [claudeDir, process.cwd()].filter((d, i, a) => a.indexOf(d) === i); + const compressed = summarizeCompressed(findCompressedPairs(scanDirs)); + process.stdout.write(formatStats({ ...parsed, mode, sessionPath: sessionFile, compressed })); } - - process.stdout.write( - `\nCaveman Stats\n${sep}\n` + - `Session: ${shortPath}\n` + - `Turns: ${turns}\n${sep}\n` + - `Output tokens: ${outputTokens.toLocaleString()}\n` + - `Cache-read tokens: ${cacheReadTokens.toLocaleString()}\n${sep}\n` + - `${savings}\n` + - (footer ? footer + '\n' : '') - ); } -main(); +if (require.main === module) main(); + +module.exports = { + formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings, + parseSession, priceForModel, formatUsd, COMPRESSION, MODEL_OUTPUT_PRICE_PER_M, + findCompressedPairs, summarizeCompressed, humanizeTokens, +}; diff --git a/hooks/caveman-statusline.ps1 b/hooks/caveman-statusline.ps1 index ab0f204..936d563 100644 --- a/hooks/caveman-statusline.ps1 +++ b/hooks/caveman-statusline.ps1 @@ -37,3 +37,23 @@ if ([string]::IsNullOrEmpty($Mode) -or $Mode -eq "full") { $Suffix = $Mode.ToUpperInvariant() [Console]::Write("${Esc}[38;5;172m[CAVEMAN:$Suffix]${Esc}[0m") } + +# Optional savings suffix: opt-in via CAVEMAN_STATUSLINE_SAVINGS=1. +# Reads a pre-rendered string written by caveman-stats.js. Refuses reparse +# points and strips control bytes (matches statusline.sh hardening). +if ($env:CAVEMAN_STATUSLINE_SAVINGS -eq "1") { + $SavingsFile = Join-Path $ClaudeDir ".caveman-statusline-suffix" + if (Test-Path $SavingsFile) { + try { + $SavingsItem = Get-Item -LiteralPath $SavingsFile -Force -ErrorAction Stop + if (-not ($SavingsItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and + $SavingsItem.Length -le 64) { + $Savings = (Get-Content -LiteralPath $SavingsFile -Raw -ErrorAction Stop).TrimEnd() + $Savings = ($Savings -replace '[\x00-\x1F]', '') + if ($Savings.Length -gt 0) { + [Console]::Write(" ${Esc}[38;5;172m$Savings${Esc}[0m") + } + } + } catch {} + } +} diff --git a/hooks/caveman-statusline.sh b/hooks/caveman-statusline.sh index 950c1a8..f9242c1 100755 --- a/hooks/caveman-statusline.sh +++ b/hooks/caveman-statusline.sh @@ -33,3 +33,16 @@ else SUFFIX=$(printf '%s' "$MODE" | tr '[:lower:]' '[:upper:]') printf '\033[38;5;172m[CAVEMAN:%s]\033[0m' "$SUFFIX" fi + +# Optional savings suffix: opt-in via CAVEMAN_STATUSLINE_SAVINGS=1. +# Reads a pre-rendered string written by caveman-stats.js so we don't shell out +# to node on every keystroke. Refuses symlinks and strips control bytes — +# same hardening as the flag file (a local attacker could plant a file with +# ANSI escape codes otherwise). +if [ "${CAVEMAN_STATUSLINE_SAVINGS:-0}" = "1" ]; then + SAVINGS_FILE="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-statusline-suffix" + if [ -f "$SAVINGS_FILE" ] && [ ! -L "$SAVINGS_FILE" ]; then + SAVINGS=$(head -c 64 "$SAVINGS_FILE" 2>/dev/null | tr -d '\000-\037') + [ -n "$SAVINGS" ] && printf ' \033[38;5;172m%s\033[0m' "$SAVINGS" + fi +fi diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..8e44cad --- /dev/null +++ b/install.ps1 @@ -0,0 +1,165 @@ +# caveman — smart multi-agent installer (Windows / PowerShell). +# +# One line: +# irm https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1 | iex +# +# Detects which AI coding agents are on your machine and installs caveman for +# each one using its native distribution. Skips agents that aren't installed. +# Safe to re-run. +# +# Flags: +# -DryRun List what would be installed and exit. +# -Only Comma-separated agent list (claude,gemini,codex,cursor, +# windsurf,cline,copilot). +# -SkipSkills Don't run the npx-skills fallback. +# -Force Re-run even if a target reports "already installed". + +[CmdletBinding()] +param( + [switch]$DryRun, + [switch]$Force, + [switch]$SkipSkills, + [string]$Only = "" +) + +$ErrorActionPreference = "Stop" +$Repo = "JuliusBrussee/caveman" +$OnlyList = if ($Only) { $Only.Split(',') | ForEach-Object { $_.Trim() } } else { @() } + +function Say($msg) { Write-Host $msg -ForegroundColor DarkYellow } +function Note($msg) { Write-Host $msg -ForegroundColor DarkGray } + +function Want($name) { + if ($OnlyList.Count -eq 0) { return $true } + return $OnlyList -contains $name +} + +function Has($cmd) { + return [bool](Get-Command $cmd -ErrorAction SilentlyContinue) +} + +function Run { + param([string]$Cmd, [string[]]$Args) + if ($DryRun) { + Note " would run: $Cmd $($Args -join ' ')" + return $true + } + Write-Host " $ $Cmd $($Args -join ' ')" + try { + & $Cmd @Args + return $LASTEXITCODE -eq 0 + } catch { + Write-Host " ✗ $($_.Exception.Message)" -ForegroundColor Red + return $false + } +} + +Say "🪨 caveman installer" +Note " $Repo" +Write-Host "" + +$Installed = @() +$Skipped = @() + +# ── Claude Code ──────────────────────────────────────────────────────────── +if ((Want "claude") -and (Has "claude")) { + Say "→ Claude Code detected" + $alreadyInstalled = $false + if (-not $Force) { + try { + $list = & claude plugin list 2>$null + if ($list -match "(?i)caveman") { $alreadyInstalled = $true } + } catch {} + } + if ($alreadyInstalled) { + Note " caveman plugin already installed (use -Force to reinstall)" + $Skipped += "claude (already installed)" + } else { + Run "claude" @("plugin", "marketplace", "add", $Repo) | Out-Null + Run "claude" @("plugin", "install", "caveman@caveman") | Out-Null + $Installed += "claude" + } + Write-Host "" +} + +# ── Gemini CLI ───────────────────────────────────────────────────────────── +if ((Want "gemini") -and (Has "gemini")) { + Say "→ Gemini CLI detected" + $alreadyInstalled = $false + if (-not $Force) { + try { + $list = & gemini extensions list 2>$null + if ($list -match "(?i)caveman") { $alreadyInstalled = $true } + } catch {} + } + if ($alreadyInstalled) { + Note " caveman extension already installed (use -Force to reinstall)" + $Skipped += "gemini (already installed)" + } else { + Run "gemini" @("extensions", "install", "https://github.com/$Repo") | Out-Null + $Installed += "gemini" + } + Write-Host "" +} + +# ── Codex ────────────────────────────────────────────────────────────────── +if ((Want "codex") -and (Has "codex")) { + Say "→ Codex CLI detected" + Run "npx" @("-y", "skills", "add", $Repo, "-a", "codex") | Out-Null + $Installed += "codex" + Write-Host "" +} + +# ── IDE rule-file targets via npx-skills ─────────────────────────────────── +$IdeTargets = @() +if ((Want "cursor") -and ((Has "cursor") -or (Test-Path "$HOME\.cursor"))) { + $IdeTargets += "cursor" +} +if ((Want "windsurf") -and ((Has "windsurf") -or + (Test-Path "$HOME\.codeium\windsurf") -or + (Test-Path "$HOME\.windsurf"))) { + $IdeTargets += "windsurf" +} +if ((Want "cline") -and (Test-Path "$HOME\.vscode\extensions")) { + $clineExt = Get-ChildItem "$HOME\.vscode\extensions" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match "(?i)cline" } + if ($clineExt) { $IdeTargets += "cline" } +} +if ((Want "copilot") -and (Has "gh")) { $IdeTargets += "github-copilot" } + +foreach ($tgt in $IdeTargets) { + Say "→ $tgt detected" + Run "npx" @("-y", "skills", "add", $Repo, "-a", $tgt) | Out-Null + $Installed += $tgt + Write-Host "" +} + +# ── Generic fallback: npx skills add (auto-detect) ───────────────────────── +# Only fire if (a) no -Only filter, (b) skills not disabled, (c) we neither +# installed nor skipped anything. Otherwise skip — see install.sh comment. +if (-not $SkipSkills -and $OnlyList.Count -eq 0 -and + $Installed.Count -eq 0 -and $Skipped.Count -eq 0) { + Say "→ no known agents detected — running npx-skills auto-detect fallback" + if (Run "npx" @("-y", "skills", "add", $Repo)) { $Installed += "skills-auto" } + Write-Host "" +} + +# ── Summary ──────────────────────────────────────────────────────────────── +Write-Host "" +Say "🪨 done" +if ($Installed.Count -gt 0) { + Write-Host " installed for:" + foreach ($a in $Installed) { Write-Host " • $a" } +} +if ($Skipped.Count -gt 0) { + Write-Host " skipped:" + foreach ($a in $Skipped) { Write-Host " • $a" } +} +if ($Installed.Count -eq 0 -and $Skipped.Count -eq 0) { + Write-Host " nothing detected. install one of: claude, gemini, cursor, windsurf, cline, codex" + Write-Host " or pass -Only to force a specific target" +} + +Write-Host "" +Note " start any session and say 'caveman mode', or run /caveman in Claude Code" +Note " uninstall: see https://github.com/$Repo#install" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..82f4eb0 --- /dev/null +++ b/install.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# caveman — smart multi-agent installer. +# +# One line: +# curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash +# +# Detects which AI coding agents are on your machine and installs caveman for +# each one using its native distribution (plugin / extension / skill / rule +# file). Skips agents that aren't installed. Safe to re-run — each underlying +# install command is idempotent. +# +# Flags: +# --dry-run List what would be installed and exit. +# --only Install only for the named agent (claude|gemini|codex| +# cursor|windsurf|cline|copilot). Repeatable. +# --skip-skills Don't run the npx-skills fallback. +# --force Re-run even if a target reports "already installed". + +set -e + +REPO="JuliusBrussee/caveman" +RAW_BASE="https://raw.githubusercontent.com/$REPO/main" +BIN_NAME="caveman" + +DRY=0 +FORCE=0 +SKIP_SKILLS=0 +ONLY=() + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --force) FORCE=1 ;; + --skip-skills) SKIP_SKILLS=1 ;; + --only) shift; ONLY+=("$1") ;; + -h|--help) + cat <] + +Detected agents (anything in PATH or with a known config dir): + claude Claude Code → plugin marketplace + plugin install + gemini Gemini CLI → gemini extensions install + codex Codex CLI → npx skills add (codex profile) + cursor Cursor IDE → npx skills add (cursor profile) + windsurf Windsurf IDE → npx skills add (windsurf profile) + cline Cline (VS Code ext) → npx skills add (cline profile) + copilot GitHub Copilot → npx skills add (github-copilot profile) + * Anything else → npx skills add (auto-detect fallback) +EOF + exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac + shift +done + +c_orange=$'\033[38;5;172m' +c_dim=$'\033[2m' +c_reset=$'\033[0m' +say() { printf '%s%s%s\n' "$c_orange" "$1" "$c_reset"; } +note() { printf '%s%s%s\n' "$c_dim" "$1" "$c_reset"; } + +# ────────────────────────────────────────────────────────────────────────── +say "🪨 caveman installer" +note " $REPO" +echo + +want() { + if [ ${#ONLY[@]} -eq 0 ]; then return 0; fi + for a in "${ONLY[@]}"; do [ "$a" = "$1" ] && return 0; done + return 1 +} + +run() { + if [ "$DRY" = 1 ]; then + note " would run: $*" + return 0 + fi + echo " $ $*" + "$@" +} + +INSTALLED=() +SKIPPED=() + +# ── Claude Code ──────────────────────────────────────────────────────────── +if want claude && command -v claude >/dev/null 2>&1; then + say "→ Claude Code detected" + if [ "$FORCE" = 1 ] || ! claude plugin list 2>/dev/null | grep -qi caveman; then + run claude plugin marketplace add "$REPO" + run claude plugin install "caveman@caveman" + INSTALLED+=("claude") + else + note " caveman plugin already installed (use --force to reinstall)" + SKIPPED+=("claude (already installed)") + fi + echo +fi + +# ── Gemini CLI ───────────────────────────────────────────────────────────── +if want gemini && command -v gemini >/dev/null 2>&1; then + say "→ Gemini CLI detected" + if [ "$FORCE" = 1 ] || ! gemini extensions list 2>/dev/null | grep -qi caveman; then + run gemini extensions install "https://github.com/$REPO" + INSTALLED+=("gemini") + else + note " caveman extension already installed (use --force to reinstall)" + SKIPPED+=("gemini (already installed)") + fi + echo +fi + +# ── Codex ────────────────────────────────────────────────────────────────── +if want codex && command -v codex >/dev/null 2>&1; then + say "→ Codex CLI detected" + run npx -y skills add "$REPO" -a codex + INSTALLED+=("codex") + echo +fi + +# ── IDE rule-file targets via npx-skills ─────────────────────────────────── +declare -a IDE_TARGETS +if want cursor && { command -v cursor >/dev/null 2>&1 || [ -d "$HOME/.cursor" ]; }; then + IDE_TARGETS+=("cursor") +fi +if want windsurf && { command -v windsurf >/dev/null 2>&1 || [ -d "$HOME/.codeium/windsurf" ] || [ -d "$HOME/.windsurf" ]; }; then + IDE_TARGETS+=("windsurf") +fi +if want cline && [ -d "$HOME/.vscode/extensions" ] && \ + ls "$HOME/.vscode/extensions" 2>/dev/null | grep -qi cline; then + IDE_TARGETS+=("cline") +fi +if want copilot && command -v gh >/dev/null 2>&1; then + IDE_TARGETS+=("github-copilot") +fi + +for tgt in "${IDE_TARGETS[@]}"; do + say "→ $tgt detected" + run npx -y skills add "$REPO" -a "$tgt" + INSTALLED+=("$tgt") + echo +done + +# ── Generic fallback: npx skills add (auto-detect) ───────────────────────── +# Only fire if (a) no --only filter was passed, (b) skills wasn't disabled, +# and (c) we neither installed nor skipped anything. Otherwise the user's +# already-installed setup or explicit --only target shouldn't be drowned in +# an unrelated fallback. +if [ "$SKIP_SKILLS" = 0 ] && [ ${#ONLY[@]} -eq 0 ] && \ + [ ${#INSTALLED[@]} -eq 0 ] && [ ${#SKIPPED[@]} -eq 0 ]; then + say "→ no known agents detected — running npx-skills auto-detect fallback" + if run npx -y skills add "$REPO"; then + INSTALLED+=("skills-auto") + fi + echo +fi + +# ── Summary ──────────────────────────────────────────────────────────────── +echo +say "🪨 done" +if [ ${#INSTALLED[@]} -gt 0 ]; then + echo " installed for:" + for a in "${INSTALLED[@]}"; do printf ' • %s\n' "$a"; done +fi +if [ ${#SKIPPED[@]} -gt 0 ]; then + echo " skipped:" + for a in "${SKIPPED[@]}"; do printf ' • %s\n' "$a"; done +fi +if [ ${#INSTALLED[@]} -eq 0 ] && [ ${#SKIPPED[@]} -eq 0 ]; then + echo " nothing detected. install one of: claude, gemini, cursor, windsurf, cline, codex" + echo " or pass --only to force a specific target" +fi + +echo +note " start any session and say 'caveman mode', or run /caveman in Claude Code" +note " uninstall: see https://github.com/$REPO#install" diff --git a/mcp-servers/caveman-shrink/README.md b/mcp-servers/caveman-shrink/README.md new file mode 100644 index 0000000..7de2b3b --- /dev/null +++ b/mcp-servers/caveman-shrink/README.md @@ -0,0 +1,58 @@ +# caveman-shrink + +> MCP middleware. Wrap any MCP server. Cut the prose. Keep the substance. + +`caveman-shrink` is a stdio proxy for the [Model Context Protocol](https://modelcontextprotocol.io). It sits between Claude (or any MCP client) and an upstream MCP server, and compresses the prose fields (`description`, etc.) using the same boundaries as the [caveman](../..) skill — preserving code, URLs, paths, and identifiers while stripping articles, filler, hedging, and pleasantries. + +The result: tool catalogs that the model burns fewer tokens to read, with no change to tool semantics. + +## Install + +```bash +npm install -g caveman-shrink +# or run directly via npx +npx caveman-shrink [...args] +``` + +## Use it + +Wrap any MCP server in your Claude Code (or other client) config: + +```jsonc +{ + "mcpServers": { + "fs-shrunk": { + "command": "npx", + "args": [ + "caveman-shrink", + "npx", "@modelcontextprotocol/server-filesystem", "/path/to/dir" + ] + } + } +} +``` + +The proxy spawns the upstream as a subprocess, intercepts `tools/list`, `prompts/list`, `resources/list` responses, and rewrites the `description` fields (and anything else you list in `CAVEMAN_SHRINK_FIELDS`). + +## What it does NOT touch + +By design, v1 is conservative: + +- **Request bodies** going to the upstream are passed through unchanged. +- **Tool call responses** (`tools/call`) are passed through unchanged. We don't want to risk silently mutating the data the upstream returns to the model. +- **Identifiers, URLs, paths, and code-looking tokens** inside any prose are preserved exactly. Same boundaries as the parent caveman skill. + +## Configuration + +| Env var | Default | What | +|---|---|---| +| `CAVEMAN_SHRINK_FIELDS` | `description` | Comma-separated list of field names to compress | +| `CAVEMAN_SHRINK_DEBUG` | `0` | Set to `1` to log per-field compression deltas to stderr | + +## Status + +Pre-1.0 — the compression rules and field set may change. The plugin is part of the [caveman ecosystem](https://github.com/JuliusBrussee/caveman); see the parent repo for the full skill suite (`caveman`, `cavemem`, `cavekit`, `cavecrew`, `caveman-stats`, `caveman-init`). + +## License + +MIT. diff --git a/mcp-servers/caveman-shrink/compress.js b/mcp-servers/caveman-shrink/compress.js new file mode 100644 index 0000000..6edbb99 Binary files /dev/null and b/mcp-servers/caveman-shrink/compress.js differ diff --git a/mcp-servers/caveman-shrink/index.js b/mcp-servers/caveman-shrink/index.js new file mode 100644 index 0000000..0744101 --- /dev/null +++ b/mcp-servers/caveman-shrink/index.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// caveman-shrink — MCP middleware that proxies an upstream MCP server and +// compresses prose fields so the model sees fewer tokens. +// +// Usage: +// caveman-shrink [...args] +// +// Example wrapping the filesystem MCP server: +// "mcpServers": { +// "fs-shrunk": { +// "command": "npx", +// "args": ["caveman-shrink", "npx", "@modelcontextprotocol/server-filesystem", "/some/path"] +// } +// } +// +// Compression is applied to: +// - "description" fields in tools/list, prompts/list, resources/list responses +// - same boundaries as caveman-compress: code, URLs, paths, identifiers preserved +// +// What we deliberately DON'T touch in v1: +// - tools/call response content (high risk of breaking downstream parsing) +// - request payloads going TO the upstream server +// +// Configuration (env vars): +// CAVEMAN_SHRINK_FIELDS comma-separated extra field names to compress +// (default: description) +// CAVEMAN_SHRINK_DEBUG=1 log compression deltas to stderr + +const { spawn } = require('child_process'); +const { compressDescriptionsInPlace, compress } = require('./compress'); + +const args = process.argv.slice(2); +if (args.length === 0) { + process.stderr.write('caveman-shrink: missing upstream command.\n'); + process.stderr.write('Usage: caveman-shrink [...args]\n'); + process.exit(2); +} + +const debug = process.env.CAVEMAN_SHRINK_DEBUG === '1'; +const fields = (process.env.CAVEMAN_SHRINK_FIELDS || 'description') + .split(',').map(s => s.trim()).filter(Boolean); + +const upstream = spawn(args[0], args.slice(1), { + stdio: ['pipe', 'pipe', 'inherit'], +}); + +upstream.on('error', err => { + process.stderr.write(`caveman-shrink: failed to spawn upstream: ${err.message}\n`); + process.exit(1); +}); + +upstream.on('exit', (code, signal) => { + if (signal) process.exit(128 + (signal === 'SIGTERM' ? 15 : 9)); + process.exit(code || 0); +}); + +// JSON-RPC framing over stdio: messages are separated by newlines (the +// MCP stdio transport uses LSP-like content but most servers emit one JSON +// object per line). We line-buffer in both directions and parse opportunistically. +function makeLineBuffer(onLine) { + let buf = ''; + return chunk => { + buf += chunk.toString('utf8'); + let nl; + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (line.trim()) onLine(line); + } + }; +} + +function transformResponse(msg) { + // Compress description fields on list-style responses. Match by method + // shape — we don't always know the original request's method, so we + // detect by the presence of a tools/prompts/resources array. + if (!msg || !msg.result || typeof msg.result !== 'object') return msg; + const r = msg.result; + let compressedSomething = false; + + for (const arrayName of ['tools', 'prompts', 'resources', 'resourceTemplates']) { + if (Array.isArray(r[arrayName])) { + for (const item of r[arrayName]) { + for (const field of fields) { + if (typeof item[field] === 'string') { + const before = item[field]; + const out = compress(before).compressed; + if (out !== before) { + item[field] = out; + compressedSomething = true; + if (debug) { + process.stderr.write( + `[caveman-shrink] ${arrayName}.${item.name || '?'}.${field}: ` + + `${before.length}→${out.length} bytes\n` + ); + } + } + } + } + } + } + } + + // Some servers stuff descriptions in nested schemas. Only walk if nothing + // matched at the top level; avoids double-processing a tool's nested params. + if (!compressedSomething) compressDescriptionsInPlace(r, fields); + + return msg; +} + +// Upstream → us → client (model). Transform here. +upstream.stdout.on('data', makeLineBuffer(line => { + let msg; + try { msg = JSON.parse(line); } catch { + // Pass through unparseable lines unchanged. + process.stdout.write(line + '\n'); + return; + } + const out = transformResponse(msg); + process.stdout.write(JSON.stringify(out) + '\n'); +})); + +// Client → us → upstream. Pass through unchanged for v1. +process.stdin.on('data', chunk => upstream.stdin.write(chunk)); +process.stdin.on('end', () => upstream.stdin.end()); diff --git a/mcp-servers/caveman-shrink/package.json b/mcp-servers/caveman-shrink/package.json new file mode 100644 index 0000000..2b4f1fb --- /dev/null +++ b/mcp-servers/caveman-shrink/package.json @@ -0,0 +1,19 @@ +{ + "name": "caveman-shrink", + "version": "0.1.0", + "description": "MCP proxy that compresses prose fields (tool descriptions, etc.) using caveman rules. Same accuracy, fewer context tokens.", + "license": "MIT", + "homepage": "https://github.com/JuliusBrussee/caveman", + "repository": { + "type": "git", + "url": "https://github.com/JuliusBrussee/caveman.git", + "directory": "mcp-servers/caveman-shrink" + }, + "author": "Julius Brussee", + "keywords": ["mcp", "model-context-protocol", "claude", "caveman", "proxy", "tokens"], + "main": "compress.js", + "bin": { + "caveman-shrink": "./index.js" + }, + "files": ["index.js", "compress.js", "README.md"] +} diff --git a/plugins/caveman/agents/cavecrew-builder.md b/plugins/caveman/agents/cavecrew-builder.md new file mode 100644 index 0000000..dd9bdfe --- /dev/null +++ b/plugins/caveman/agents/cavecrew-builder.md @@ -0,0 +1,44 @@ +--- +name: cavecrew-builder +description: > + Small targeted edits in one or two files. Caveman-ultra style. Use for + typo fixes, single-function changes, mechanical refactors. Returns a + caveman-style summary of exactly what changed. Don't use for large + refactors or new features. +tools: Read, Edit, Write, Grep, Glob, Bash +--- + +You are cavecrew-builder. Caveman-mode, ultra intensity. Small surgical edits. + +## Scope + +- One file ideal. Two files OK. Three+ → return "Too big — split task." +- New code only when the user explicitly asked. Default = edit existing. +- No new abstractions, no refactors-on-the-side, no comment additions unless asked. + +## Workflow + +1. Read the target file(s) before editing — never edit blind. +2. Make the change with the smallest diff that works. +3. If tests exist nearby, run them. If they fail because of your change, fix or revert. +4. Return a caveman-style summary. Format below. + +## Output shape + +``` +. +. +Tests: . +``` + +Do not narrate exploration. The diff is the artifact; the summary is the receipt. + +## Boundaries + +If the task can't be done in <=2 files, return: `Too big — split into N tasks. Suggested splits: ...` + +If the change requires destructive ops (rm -rf, force-push, drop table), return: `Needs user confirm — destructive op: .` Do not execute. + +## Caveman rules (inherited) + +Drop articles/filler/pleasantries. Fragments OK. Code/symbols/paths exact and backticked. Auto-clarity for security warnings and irreversible-action confirmations. diff --git a/plugins/caveman/agents/cavecrew-investigator.md b/plugins/caveman/agents/cavecrew-investigator.md new file mode 100644 index 0000000..890a99f --- /dev/null +++ b/plugins/caveman/agents/cavecrew-investigator.md @@ -0,0 +1,52 @@ +--- +name: cavecrew-investigator +description: > + Read-only codebase explorer. Caveman-ultra style. Use to locate files, + find symbols, map directory structure, summarize code paths. Never edits. + Output is fragment-style with file:line references, no commentary. +tools: Read, Grep, Glob, Bash +--- + +You are cavecrew-investigator. Caveman-mode, ultra intensity. Read-only. + +## Job + +Find things in the codebase. Report locations. Nothing else. + +## Output shape + +- Lead with the answer fragment-style. No "I'll look into this" / "Let me search." +- File references as `path/to/file.ts:42` so the user can jump to them. +- Function and symbol names in backticks: `myFunc`. +- Group findings under a one-word header when there are 3+: `Defs:` / `Callers:` / `Tests:`. +- If nothing found, say "No match." Do not pad with "I searched X, Y, Z and found nothing." + +## Tools + +Use `Grep` for symbol/text search. Use `Glob` for file-pattern search. Use `Read` only for the specific file ranges you need. `Bash` for git/find when faster. + +## Boundaries + +Never edit, never write, never suggest fixes. If asked to fix, return: "Investigator read-only — spawn cavecrew-builder." + +## Caveman rules (inherited) + +Drop articles/filler/pleasantries. Fragments OK. Code/symbols/paths exact and backticked. Auto-clarity for security warnings and irreversible-action confirmations. + +## Example + +User: "Where is the symlink-safe flag write?" + +Bad: "I searched the repository and found that the symlink-safe flag write logic is implemented in the `safeWriteFlag` function..." + +Good: +``` +Defs: +- hooks/caveman-config.js:81 — safeWriteFlag +- hooks/caveman-config.js:160 — readFlag +Callers: +- hooks/caveman-mode-tracker.js:33,87 +- hooks/caveman-activate.js:40 +Tests: +- tests/test_symlink_flag.js (12 tests) +``` diff --git a/plugins/caveman/agents/cavecrew-reviewer.md b/plugins/caveman/agents/cavecrew-reviewer.md new file mode 100644 index 0000000..c62a95b --- /dev/null +++ b/plugins/caveman/agents/cavecrew-reviewer.md @@ -0,0 +1,43 @@ +--- +name: cavecrew-reviewer +description: > + Reviews diffs, branches, or files. One-line-per-finding output following + the caveman-review skill (`L: . .`). + No throat-clearing, no praise, no scope creep. Use for PR-style reviews. +tools: Read, Grep, Bash +--- + +You are cavecrew-reviewer. Caveman-mode, ultra intensity. Read-only review. + +## Scope + +- Review what's in front of you (diff / files / branch). Don't expand scope to "while we're here." +- Severity tiers: 🔴 bug, 🟡 risk, 🔵 nit, ❓ question. Skip 🔵 unless asked for thorough review. +- No praise. No "looks good!" The absence of findings *is* the praise. + +## Output shape + +One line per finding, in file order: + +``` +:: : . . +``` + +Examples: +``` +src/auth.ts:42: 🔴 bug: token expiry uses `<` not `<=`. Off-by-one allows expired tokens for one tick. +src/db.ts:118: 🟡 risk: pool not closed on error path. Add `try/finally`. +src/utils.ts:7: ❓ question: why duplicate `.trim()` here? +``` + +If nothing found: `No issues.` + +## Boundaries + +- Don't suggest large refactors. Out of scope. +- Don't review formatting unless it changes meaning. +- If a finding requires more context, append `(see L in )` rather than guessing. + +## Caveman rules (inherited) + +Drop articles/filler/pleasantries. Fragments OK. Code/symbols/paths exact and backticked. Auto-clarity for security warnings and irreversible-action confirmations. diff --git a/plugins/caveman/skills/cavecrew/SKILL.md b/plugins/caveman/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..9b3c8a7 --- /dev/null +++ b/plugins/caveman/skills/cavecrew/SKILL.md @@ -0,0 +1,39 @@ +--- +name: cavecrew +description: > + Caveman-flavored subagent presets. When you need a subagent for research, + small edits, or code review, prefer the cavecrew variants + (cavecrew-investigator / cavecrew-builder / cavecrew-reviewer). They are + caveman-mode-by-default at ultra intensity and use machine-to-machine + caveman grammar in handoffs to other subagents. + Trigger phrases: "use cavecrew", "spawn investigator", "spawn builder", + "spawn reviewer", "give this to a subagent", "delegate this". +--- + +Cavecrew = caveman ruleset applied to subagents (not chat-with-user). + +## When to use cavecrew vs vanilla subagents + +Use vanilla subagents when the user asked for prose-y human-readable output. Use cavecrew when: + +- The output is for another agent / pipeline step (machine-to-machine). +- You're spawning multiple subagents and the cumulative prose blowup matters. +- The user has caveman mode active — keep the style consistent across the session. + +## Three presets (in `plugins/caveman/agents/`) + +| Subagent | When | Output shape | +|---|---|---| +| `cavecrew-investigator` | Read-only research, locate files, map structure. Defer to it for "where is X defined" / "what calls Y" / "summarize this dir." | Caveman-ultra prose, file paths backticked, line numbers in `file.ts:42` form. No suggestions. | +| `cavecrew-builder` | Small targeted edits in one or two files. Defer for typo fixes, single-function changes, mechanical refactors. | Caveman-ultra commit-message-style summary of what changed. | +| `cavecrew-reviewer` | Review a diff or branch. Defer for PR-style review. | One-line-per-finding comments per `caveman-review` skill: `L: . .` | + +## Composition rules + +- All three import the canonical caveman ruleset from `skills/caveman/SKILL.md` at intensity `ultra`. +- Code blocks, file paths, function names, error strings: never abbreviated. Same boundary rules as the base caveman skill. +- Subagent → subagent handoffs use caveman-internal grammar (terse machine-to-machine, no whimsy). User-facing summaries can soften slightly if asked. + +## Auto-clarity + +Inherit from caveman: drop to normal prose for security warnings, irreversible action confirmations, multi-step sequences where fragment ambiguity risks misread. Otherwise stay caveman. diff --git a/skills/cavecrew/SKILL.md b/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..9b3c8a7 --- /dev/null +++ b/skills/cavecrew/SKILL.md @@ -0,0 +1,39 @@ +--- +name: cavecrew +description: > + Caveman-flavored subagent presets. When you need a subagent for research, + small edits, or code review, prefer the cavecrew variants + (cavecrew-investigator / cavecrew-builder / cavecrew-reviewer). They are + caveman-mode-by-default at ultra intensity and use machine-to-machine + caveman grammar in handoffs to other subagents. + Trigger phrases: "use cavecrew", "spawn investigator", "spawn builder", + "spawn reviewer", "give this to a subagent", "delegate this". +--- + +Cavecrew = caveman ruleset applied to subagents (not chat-with-user). + +## When to use cavecrew vs vanilla subagents + +Use vanilla subagents when the user asked for prose-y human-readable output. Use cavecrew when: + +- The output is for another agent / pipeline step (machine-to-machine). +- You're spawning multiple subagents and the cumulative prose blowup matters. +- The user has caveman mode active — keep the style consistent across the session. + +## Three presets (in `plugins/caveman/agents/`) + +| Subagent | When | Output shape | +|---|---|---| +| `cavecrew-investigator` | Read-only research, locate files, map structure. Defer to it for "where is X defined" / "what calls Y" / "summarize this dir." | Caveman-ultra prose, file paths backticked, line numbers in `file.ts:42` form. No suggestions. | +| `cavecrew-builder` | Small targeted edits in one or two files. Defer for typo fixes, single-function changes, mechanical refactors. | Caveman-ultra commit-message-style summary of what changed. | +| `cavecrew-reviewer` | Review a diff or branch. Defer for PR-style review. | One-line-per-finding comments per `caveman-review` skill: `L: . .` | + +## Composition rules + +- All three import the canonical caveman ruleset from `skills/caveman/SKILL.md` at intensity `ultra`. +- Code blocks, file paths, function names, error strings: never abbreviated. Same boundary rules as the base caveman skill. +- Subagent → subagent handoffs use caveman-internal grammar (terse machine-to-machine, no whimsy). User-facing summaries can soften slightly if asked. + +## Auto-clarity + +Inherit from caveman: drop to normal prose for security warnings, irreversible action confirmations, multi-step sequences where fragment ambiguity risks misread. Otherwise stay caveman. diff --git a/tests/test_caveman_init.js b/tests/test_caveman_init.js new file mode 100644 index 0000000..448c7f8 --- /dev/null +++ b/tests/test_caveman_init.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +// Tests for tools/caveman-init.js — fixture-based. +// Run: node tests/test_caveman_init.js + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const assert = require('assert'); +const { execFileSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const INIT = path.join(ROOT, 'tools', 'caveman-init.js'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-init-test-')); + try { + fn(tmp); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}\n ${e.message}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +console.log('caveman-init tests\n'); + +test('greenfield: creates all rule files with proper frontmatter', (tmp) => { + execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' }); + const cursor = fs.readFileSync(path.join(tmp, '.cursor/rules/caveman.mdc'), 'utf8'); + assert.match(cursor, /alwaysApply: true/); + assert.match(cursor, /Respond terse like smart caveman/); + const windsurf = fs.readFileSync(path.join(tmp, '.windsurf/rules/caveman.md'), 'utf8'); + assert.match(windsurf, /trigger: always_on/); + const cline = fs.readFileSync(path.join(tmp, '.clinerules/caveman.md'), 'utf8'); + assert.match(cline, /^Respond terse/); + const copilot = fs.readFileSync(path.join(tmp, '.github/copilot-instructions.md'), 'utf8'); + assert.match(copilot, /Respond terse/); + const agents = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8'); + assert.match(agents, /Respond terse/); +}); + +test('idempotent: re-running on a clean install skips all', (tmp) => { + execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' }); + const out = execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' }); + assert.match(out, /5 skipped/); + assert.doesNotMatch(out, /[1-9]\d* added/); +}); + +test('append mode: existing AGENTS.md gets caveman appended (not replaced)', (tmp) => { + fs.writeFileSync(path.join(tmp, 'AGENTS.md'), '# My project\n\nDo not delete me.\n'); + execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' }); + const agents = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8'); + assert.match(agents, /Do not delete me/); + assert.match(agents, /Respond terse like smart caveman/); +}); + +test('skip mode: existing .cursor rule is not overwritten without --force', (tmp) => { + const dir = path.join(tmp, '.cursor/rules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'caveman.mdc'), '# original\nDo not delete me.\n'); + const out = execFileSync(process.execPath, [INIT, tmp], { encoding: 'utf8' }); + assert.match(out, /\? .*\.cursor\/rules\/caveman\.mdc/); + const after = fs.readFileSync(path.join(dir, 'caveman.mdc'), 'utf8'); + assert.strictEqual(after, '# original\nDo not delete me.\n'); +}); + +test('--force overwrites existing rule files', (tmp) => { + const dir = path.join(tmp, '.cursor/rules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'caveman.mdc'), '# original\n'); + execFileSync(process.execPath, [INIT, tmp, '--force'], { encoding: 'utf8' }); + const after = fs.readFileSync(path.join(dir, 'caveman.mdc'), 'utf8'); + assert.match(after, /alwaysApply: true/); + assert.match(after, /Respond terse/); +}); + +test('--dry-run: announces but writes nothing', (tmp) => { + const out = execFileSync(process.execPath, [INIT, tmp, '--dry-run'], { encoding: 'utf8' }); + assert.match(out, /\(dry run\)/); + assert.match(out, /5 added/); + assert.ok(!fs.existsSync(path.join(tmp, '.cursor'))); + assert.ok(!fs.existsSync(path.join(tmp, '.windsurf'))); + assert.ok(!fs.existsSync(path.join(tmp, '.clinerules'))); + assert.ok(!fs.existsSync(path.join(tmp, '.github/copilot-instructions.md'))); + assert.ok(!fs.existsSync(path.join(tmp, 'AGENTS.md'))); +}); + +test('--only filters to one target', (tmp) => { + const out = execFileSync(process.execPath, [INIT, tmp, '--only', 'cline'], { encoding: 'utf8' }); + assert.match(out, /1 added/); + assert.ok(fs.existsSync(path.join(tmp, '.clinerules/caveman.md'))); + assert.ok(!fs.existsSync(path.join(tmp, '.cursor'))); +}); + +test('detects sentinel and skips files that already have caveman content', (tmp) => { + // Hand-write a file that already contains the rule (simulating prior install). + const dir = path.join(tmp, '.clinerules'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'caveman.md'), + '# Existing\n\nRespond terse like smart caveman. Hello.\n'); + const out = execFileSync(process.execPath, [INIT, tmp, '--only', 'cline'], { encoding: 'utf8' }); + assert.match(out, /skipped-already-installed/); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/tests/test_caveman_stats.js b/tests/test_caveman_stats.js index 28a71e4..bc04d27 100644 --- a/tests/test_caveman_stats.js +++ b/tests/test_caveman_stats.js @@ -127,5 +127,306 @@ test('mode tracker preserves caveman flag when /caveman-stats fires', (tmp) => { assert.strictEqual(fs.readFileSync(path.join(claudeDir, '.caveman-active'), 'utf8'), 'full'); }); +test('shows USD savings when model is a known sonnet variant', (tmp) => { + // 350 / 0.35 = 1000, saved = 650 tokens. At $15/M output → $0.00975. + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-20250514', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // 650/1M * $15 = $0.00975 — JS toFixed(4) rounds the float repr to 0.0097. + assert.match(out, /Est\. saved \(USD\):\s+~\$0\.009[78]/); + assert.match(out, /Pricing for claude-sonnet-4-20250514/); +}); + +test('omits USD line when model is unknown', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'some-future-model-xyz', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // Token estimate still appears, USD line does not. + assert.match(out, /Est\. tokens saved:\s+650 \(~65%\)/); + assert.doesNotMatch(out, /Est\. saved \(USD\)/); +}); + +test('priceForModel matches by prefix across point releases', () => { + const { priceForModel } = require(path.join(ROOT, 'hooks', 'caveman-stats.js')); + assert.strictEqual(priceForModel('claude-opus-4-7'), 75.00); + assert.strictEqual(priceForModel('claude-opus-4-20250101'), 75.00); + assert.strictEqual(priceForModel('claude-sonnet-4-7-20260315'), 15.00); + assert.strictEqual(priceForModel('claude-haiku-4-5'), 4.00); + assert.strictEqual(priceForModel('claude-3-5-sonnet-20241022'), 15.00); + assert.strictEqual(priceForModel(null), null); + assert.strictEqual(priceForModel('gpt-4'), null); +}); + +test('formatStats handles empty session gracefully', () => { + const { formatStats } = require(path.join(ROOT, 'hooks', 'caveman-stats.js')); + const out = formatStats({ outputTokens: 0, cacheReadTokens: 0, turns: 0, mode: 'full', model: null }); + assert.match(out, /No conversation yet/); +}); + +test('--share prints single-line tweetable summary', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess, '--share'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.strictEqual(out.split('\n').filter(Boolean).length, 1); + assert.match(out, /^🪨 Saved 650 output tokens \(~\$0\.009[78]\) across 1 turns this session — caveman\.sh$/m); +}); + +test('--share works with no benchmark ratio (lite mode)', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 200 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'lite'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess, '--share'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.match(out, /^🪨 1 turns, 200 output tokens this session — caveman\.sh$/m); +}); + +test('appends to lifetime history on each run', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + const histPath = path.join(claudeDir, '.caveman-history.jsonl'); + assert.ok(fs.existsSync(histPath), 'history file should be created'); + const lines = fs.readFileSync(histPath, 'utf8').split('\n').filter(Boolean); + assert.strictEqual(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.strictEqual(entry.session_id, 's'); + assert.strictEqual(entry.output_tokens, 350); + assert.strictEqual(entry.est_saved_tokens, 650); + assert.strictEqual(entry.mode, 'full'); + assert.strictEqual(entry.model, 'claude-sonnet-4-7'); +}); + +test('--all aggregates latest entry per session', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const histPath = path.join(claudeDir, '.caveman-history.jsonl'); + // Two sessions, second one has two snapshots — only latest counts. + fs.writeFileSync(histPath, [ + { ts: 1000, session_id: 'a', mode: 'full', output_tokens: 100, est_saved_tokens: 185, est_saved_usd: 0.0028 }, + { ts: 2000, session_id: 'b', mode: 'full', output_tokens: 50, est_saved_tokens: 92, est_saved_usd: 0.0014 }, + { ts: 3000, session_id: 'b', mode: 'full', output_tokens: 200, est_saved_tokens: 371, est_saved_usd: 0.0056 }, + ].map(o => JSON.stringify(o)).join('\n') + '\n'); + const out = execFileSync(process.execPath, [STATS, '--all'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // a: 185 + b-latest: 371 = 556 + assert.match(out, /Sessions:\s+2/); + assert.match(out, /Est\. tokens saved:\s+556/); + // 0.0028 + 0.0056 = 0.0084 → formatted as $0.0084 + assert.match(out, /\$0\.0084/); +}); + +test('--since filters by time window', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const histPath = path.join(claudeDir, '.caveman-history.jsonl'); + const now = Date.now(); + const twoDaysAgo = now - 2 * 86_400_000; + const tenMinAgo = now - 10 * 60_000; + fs.writeFileSync(histPath, [ + { ts: twoDaysAgo, session_id: 'old', mode: 'full', output_tokens: 100, est_saved_tokens: 185, est_saved_usd: 0.003 }, + { ts: tenMinAgo, session_id: 'new', mode: 'full', output_tokens: 50, est_saved_tokens: 92, est_saved_usd: 0.001 }, + ].map(o => JSON.stringify(o)).join('\n') + '\n'); + const out = execFileSync(process.execPath, [STATS, '--since', '1d'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // Only the recent session is counted. + assert.match(out, /Sessions:\s+1/); + assert.match(out, /Est\. tokens saved:\s+92/); + assert.match(out, /\(last 1d\)/); +}); + +test('--since rejects malformed durations', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + let err = null; + try { + execFileSync(process.execPath, [STATS, '--since', 'sometime'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + } catch (e) { err = e; } + assert.ok(err, 'should exit non-zero'); + assert.match(err.stderr, /--since takes Nh or Nd/); +}); + +test('--all reports empty when no history', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const out = execFileSync(process.execPath, [STATS, '--all'], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.match(out, /No sessions logged yet/); +}); + +test('detects compressed memory pairs and reports approx token savings', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + // Make a fake compressed/original pair: original is 800 bytes, compressed 200 bytes. + fs.writeFileSync(path.join(claudeDir, 'CLAUDE.original.md'), 'x'.repeat(800)); + fs.writeFileSync(path.join(claudeDir, 'CLAUDE.md'), 'y'.repeat(200)); + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 100 } } }, + ]); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + // 600 bytes / 4 chars-per-token ≈ 150 tokens (approx). + assert.match(out, /Memory compressed:\s+1 file, ~150 tokens saved per session start/); +}); + +test('omits memory line when no compressed pairs exist', (tmp) => { + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sess = makeSession(tmp, [ + { type: 'assistant', message: { usage: { output_tokens: 100 } } }, + ]); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + assert.doesNotMatch(out, /Memory compressed/); +}); + +test('skips pairs where compressed is not actually smaller', (tmp) => { + const { findCompressedPairs } = require(path.join(ROOT, 'hooks', 'caveman-stats.js')); + fs.writeFileSync(path.join(tmp, 'foo.original.md'), 'small'); + fs.writeFileSync(path.join(tmp, 'foo.md'), 'this is actually larger somehow'); + const pairs = findCompressedPairs([tmp]); + assert.strictEqual(pairs.length, 0); +}); + +test('writes statusline suffix file after a stats run', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 1500 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + execFileSync(process.execPath, [STATS, '--session-file', sess], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }, + }); + const suffixPath = path.join(claudeDir, '.caveman-statusline-suffix'); + assert.ok(fs.existsSync(suffixPath)); + // 1500 / 0.35 = 4286, saved = 2786 → "⛏ 2.8k" + const suffix = fs.readFileSync(suffixPath, 'utf8'); + assert.match(suffix, /^⛏ 2\.8k$/); +}); + +test('humanizeTokens formats small/medium/large correctly', () => { + const { humanizeTokens } = require(path.join(ROOT, 'hooks', 'caveman-stats.js')); + assert.strictEqual(humanizeTokens(0), '0'); + assert.strictEqual(humanizeTokens(42), '42'); + assert.strictEqual(humanizeTokens(2786), '2.8k'); + assert.strictEqual(humanizeTokens(1_250_000), '1.3M'); +}); + +test('statusline.sh appends savings when CAVEMAN_STATUSLINE_SAVINGS=1', (tmp) => { + if (process.platform === 'win32') return; // bash test + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), '⛏ 2.8k'); + const out = execFileSync('bash', [path.join(ROOT, 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, CAVEMAN_STATUSLINE_SAVINGS: '1' }, + }); + assert.match(out, /\[CAVEMAN\]/); + assert.match(out, /⛏ 2\.8k/); +}); + +test('statusline.sh omits savings when env var is not set', (tmp) => { + if (process.platform === 'win32') return; + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), '⛏ 2.8k'); + const env = { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }; + delete env.CAVEMAN_STATUSLINE_SAVINGS; + const out = execFileSync('bash', [path.join(ROOT, 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', env, + }); + assert.match(out, /\[CAVEMAN\]/); + assert.doesNotMatch(out, /⛏/); +}); + +test('statusline.sh strips control bytes from suffix', (tmp) => { + if (process.platform === 'win32') return; + const claudeDir = path.join(tmp, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + // Plant a malicious suffix with ANSI escape (control byte \x1b). + fs.writeFileSync(path.join(claudeDir, '.caveman-statusline-suffix'), '\x1b[31mEVIL'); + const out = execFileSync('bash', [path.join(ROOT, 'hooks', 'caveman-statusline.sh')], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, CAVEMAN_STATUSLINE_SAVINGS: '1' }, + }); + // Escape byte stripped; "[31mEVIL" remains, but the leading \x1b is gone so + // the user's terminal won't be hijacked. + assert.doesNotMatch(out, /\x1b\[31m/); +}); + +test('appendFlag is symlink-safe (refuses symlinked target)', (tmp) => { + if (process.platform === 'win32') return; // symlink semantics differ + const { appendFlag } = require(path.join(ROOT, 'hooks', 'caveman-config.js')); + const target = path.join(tmp, 'real-target'); + fs.writeFileSync(target, 'do-not-clobber\n'); + const linkPath = path.join(tmp, 'history.jsonl'); + fs.symlinkSync(target, linkPath); + appendFlag(linkPath, JSON.stringify({ ts: 1, session_id: 'x' })); + // Original target must be untouched. + assert.strictEqual(fs.readFileSync(target, 'utf8'), 'do-not-clobber\n'); +}); + +test('mode tracker forwards --share to stats script', (tmp) => { + const sess = makeSession(tmp, [ + { type: 'assistant', message: { model: 'claude-sonnet-4-7', usage: { output_tokens: 350 } } }, + ]); + const claudeDir = path.join(tmp, '.claude'); + fs.writeFileSync(path.join(claudeDir, '.caveman-active'), 'full'); + const out = execFileSync(process.execPath, [TRACKER], { + encoding: 'utf8', + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp }, + input: JSON.stringify({ prompt: '/caveman-stats --share', transcript_path: sess }), + }); + const parsed = JSON.parse(out); + assert.strictEqual(parsed.decision, 'block'); + assert.match(parsed.reason, /^🪨 Saved 650 output tokens/); +}); + console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); diff --git a/tests/test_mcp_shrink.js b/tests/test_mcp_shrink.js new file mode 100644 index 0000000..2a2925f --- /dev/null +++ b/tests/test_mcp_shrink.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Tests for mcp-servers/caveman-shrink/compress.js — pure-Node prose compressor. +// Run: node tests/test_mcp_shrink.js + +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.resolve(__dirname, '..'); +const { compress, compressDescriptionsInPlace } = require( + path.join(ROOT, 'mcp-servers', 'caveman-shrink', 'compress.js') +); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}\n ${e.message}`); + } +} + +console.log('mcp-shrink compress tests\n'); + +test('drops articles', () => { + const { compressed } = compress('The user is the owner of an account'); + assert.match(compressed, /User is owner of account/i); + // No leftover lone "the" / "an" / "a" + assert.doesNotMatch(compressed, /\bthe\b/i); + assert.doesNotMatch(compressed, /\ban\b/i); +}); + +test('drops filler and pleasantries', () => { + const { compressed } = compress('Sure, this just basically returns the value'); + assert.doesNotMatch(compressed, /sure/i); + assert.doesNotMatch(compressed, /just/i); + assert.doesNotMatch(compressed, /basically/i); +}); + +test('drops hedging and "I will" leaders', () => { + const { compressed } = compress('I will perhaps connect to the database'); + assert.doesNotMatch(compressed, /perhaps/i); + assert.doesNotMatch(compressed, /^I will/i); + assert.match(compressed, /database/i); +}); + +test('preserves fenced code blocks verbatim', () => { + const input = 'Run the example: ```\nthe just sure return 1;\n``` and also more text'; + const { compressed } = compress(input); + // Inside the fence, "the just sure" must survive untouched. + assert.match(compressed, /```\nthe just sure return 1;\n```/); +}); + +test('preserves inline code verbatim', () => { + const input = 'Use `the just basically API` for fetching'; + const { compressed } = compress(input); + assert.match(compressed, /`the just basically API`/); +}); + +test('preserves URLs verbatim', () => { + const input = 'See the docs at https://example.com/the/just/api'; + const { compressed } = compress(input); + assert.match(compressed, /https:\/\/example\.com\/the\/just\/api/); +}); + +test('preserves filesystem paths verbatim', () => { + const input = 'Read just the file at /tmp/the/just/file.txt'; + const { compressed } = compress(input); + assert.match(compressed, /\/tmp\/the\/just\/file\.txt/); +}); + +test('preserves identifiers in CONST_CASE / dotted form', () => { + const input = 'Set the API_KEY_VALUE on the just config.api.endpoint()'; + const { compressed } = compress(input); + assert.match(compressed, /API_KEY_VALUE/); + assert.match(compressed, /config\.api\.endpoint\(\)/); +}); + +test('compresses real MCP-style description', () => { + const input = 'Get the current weather for a given location. ' + + 'Returns the temperature in Fahrenheit. ' + + 'Please make sure to provide the location as a city name.'; + const { compressed, before, after } = compress(input); + assert.ok(after < before, `expected size reduction, got ${before}→${after}`); + // ~30% reduction is the floor; descriptions like this should compress well. + assert.ok((before - after) / before > 0.15, `wanted >15% savings, got ${(before - after) / before}`); + // Substance preserved + assert.match(compressed, /weather/i); + assert.match(compressed, /Fahrenheit/i); + assert.match(compressed, /city name/i); +}); + +test('handles empty / null input gracefully', () => { + assert.deepStrictEqual(compress(''), { compressed: '', before: 0, after: 0 }); + const r = compress(null); + assert.strictEqual(r.compressed, null); +}); + +test('compressDescriptionsInPlace walks nested tools/list response', () => { + const payload = { + result: { + tools: [ + { name: 'get_weather', description: 'The function returns the current weather for a city.' }, + { name: 'send_email', description: 'Sends an email to a given recipient.' }, + ] + } + }; + compressDescriptionsInPlace(payload.result, ['description']); + assert.ok(!payload.result.tools[0].description.match(/\bthe\b/i), + `expected 'the' stripped, got: ${payload.result.tools[0].description}`); + assert.match(payload.result.tools[0].description, /weather/i); + assert.match(payload.result.tools[1].description, /email/i); +}); + +test('compressDescriptionsInPlace skips non-string description fields', () => { + const obj = { description: { not: 'a string' }, name: 'x' }; + // Should not throw. + compressDescriptionsInPlace(obj, ['description']); + assert.deepStrictEqual(obj.description, { not: 'a string' }); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/tools/caveman-init.js b/tools/caveman-init.js new file mode 100644 index 0000000..df4ad30 --- /dev/null +++ b/tools/caveman-init.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// caveman init — drop the always-on caveman activation rule into a target +// repo for every IDE agent we support. Idempotent. Safe to re-run. +// +// Usage: +// node tools/caveman-init.js [target-dir] [--dry-run] [--force] [--only ] +// curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/tools/caveman-init.js | node - [args] +// +// Without args, runs in cwd. Generates the rule files for Cursor, Windsurf, +// Cline, Copilot, and AGENTS.md. Does NOT modify CLAUDE.md or compress +// existing memory files — that's the job of `/caveman:compress`. + +const fs = require('fs'); +const path = require('path'); + +// Embedded so the tool works standalone (npx-style) without the rules/ dir. +// Mirrors rules/caveman-activate.md verbatim — keep these in sync. +const RULE_BODY = `Respond terse like smart caveman. All technical substance stay. Only fluff die. + +Rules: +- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging +- Fragments OK. Short synonyms. Technical terms exact. Code unchanged. +- Pattern: [thing] [action] [reason]. [next step]. +- Not: "Sure! I'd be happy to help you with that." +- Yes: "Bug in auth middleware. Fix:" + +Switch level: /caveman lite|full|ultra|wenyan +Stop: "stop caveman" or "normal mode" + +Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after. + +Boundaries: code/commits/PRs written normal. +`; + +const SENTINEL = 'Respond terse like smart caveman'; + +const AGENTS = [ + { id: 'cursor', file: '.cursor/rules/caveman.mdc', + frontmatter: '---\ndescription: "Caveman mode — terse communication, ~75% fewer tokens, full technical accuracy"\nalwaysApply: true\n---\n\n', + mode: 'replace' }, + { id: 'windsurf', file: '.windsurf/rules/caveman.md', + frontmatter: '---\ntrigger: always_on\n---\n\n', + mode: 'replace' }, + { id: 'cline', file: '.clinerules/caveman.md', + frontmatter: '', + mode: 'replace' }, + { id: 'copilot', file: '.github/copilot-instructions.md', + frontmatter: '', + mode: 'append' }, + { id: 'agents', file: 'AGENTS.md', + frontmatter: '', + mode: 'append' }, +]; + +function loadRuleBody() { + // Prefer the in-repo source-of-truth when available. + try { + const local = path.join(__dirname, '..', 'rules', 'caveman-activate.md'); + if (fs.existsSync(local)) return fs.readFileSync(local, 'utf8').trimEnd() + '\n'; + } catch (e) {} + return RULE_BODY; +} + +function processAgent(agent, targetDir, ruleBody, opts) { + const fullPath = path.join(targetDir, agent.file); + const exists = fs.existsSync(fullPath); + + if (!exists) { + if (!opts.dryRun) { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, agent.frontmatter + ruleBody, { mode: 0o644 }); + } + return { status: 'added', label: '+' }; + } + + const existing = fs.readFileSync(fullPath, 'utf8'); + if (existing.includes(SENTINEL)) { + return { status: 'skipped-already-installed', label: '=' }; + } + + if (agent.mode === 'append') { + if (!opts.dryRun) { + const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n'); + fs.writeFileSync(fullPath, existing + sep + ruleBody, { mode: 0o644 }); + } + return { status: 'appended', label: '~' }; + } + + if (opts.force) { + if (!opts.dryRun) { + fs.writeFileSync(fullPath, agent.frontmatter + ruleBody, { mode: 0o644 }); + } + return { status: 'overwritten', label: '!' }; + } + + return { status: 'skipped-exists', label: '?' }; +} + +function parseArgs(argv) { + const opts = { dryRun: false, force: false, only: null, target: process.cwd() }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--dry-run') opts.dryRun = true; + else if (a === '--force' || a === '-f') opts.force = true; + else if (a === '--only') { opts.only = argv[++i]; } + else if (a === '-h' || a === '--help') opts.help = true; + else if (!a.startsWith('-')) opts.target = path.resolve(a); + } + return opts; +} + +function help() { + console.log(`caveman init — drop always-on caveman rule into a target repo + +Usage: caveman-init.js [target-dir] [--dry-run] [--force] [--only ] + +Defaults to current working directory. Idempotent — safe to re-run. + +Targets installed: +${AGENTS.map(a => ` ${a.id.padEnd(10)} ${a.file}`).join('\n')} + +Flags: + --dry-run show what would change, do not write + --force overwrite existing rule files (default: skip) + --only only install for one agent (id from list above) +`); +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { help(); return; } + + console.log(`🪨 caveman init — ${opts.target}${opts.dryRun ? ' (dry run)' : ''}\n`); + + const ruleBody = loadRuleBody(); + const counts = { added: 0, appended: 0, overwritten: 0, skipped: 0 }; + + for (const agent of AGENTS) { + if (opts.only && opts.only !== agent.id) continue; + const result = processAgent(agent, opts.target, ruleBody, opts); + console.log(` ${result.label} ${agent.file} (${result.status})`); + if (result.status === 'added') counts.added++; + else if (result.status === 'appended') counts.appended++; + else if (result.status === 'overwritten') counts.overwritten++; + else counts.skipped++; + } + + console.log(`\n${counts.added} added, ${counts.appended} appended, ` + + `${counts.overwritten} overwritten, ${counts.skipped} skipped`); + if (opts.dryRun) console.log('(dry run — no files were written)'); +} + +if (require.main === module) main(); + +module.exports = { processAgent, loadRuleBody, AGENTS, SENTINEL, RULE_BODY };