Hooks
What pith runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add abhisekjha/pith > /plugin install pith@pith
Ships with pith. Installing the plugin gets these hooks.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
node ${CLAUDE_PLUGIN_ROOT}/hooks/prompt-submit.js
PostToolUse
node ${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.js
Stop
node ${CLAUDE_PLUGIN_ROOT}/hooks/stop.js
In the plugin's words
How pith describes its own hook set.
Token optimization hooks: compress tool output, track usage, auto-compact context
Where it lives
- hooks/config.jsGitHub
Read the script
'use strict'; // Pith — config and state management // Shared by all hooks via require('./config') const path = require('path'); const os = require('os'); const fs = require('fs'); const PITH_DIR = path.join(os.homedir(), '.pith'); const STATE_PATH = path.join(PITH_DIR, 'state.json'); const CONFIG_PATHS = [ process.env.PITH_CONFIG, path.join(os.homedir(), '.config', 'pith', 'config.json'), path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'pith', 'config.json'), ].filter(Boolean); const DEFAULTS = { default_mode: 'off', auto_compact: true, auto_compact_threshold: 0.70, tool_compress: true, tool_compress_threshold: 30, // lines before compression kicks in offload_threshold: 300, // tokens after compression before offloading to file offload_stale_turns: 5, // turns before a result is considered stale auto_escalate: true, // SWEzze: ratchet output mode as context fills escalate_lean_at: 0.50, // context fill % to auto-switch to lean (if mode is off) escalate_ultra_at: 0.70, // context fill % to auto-switch to ultra context_limit: 200000, // default Claude context window size budget: null, wiki_dir: 'wiki', }; function loadConfig() { for (const p of CONFIG_PATHS) { try { if (fs.existsSync(p)) { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(p, 'utf8')) }; } } catch (e) { /* silent */ } } return { ...DEFAULTS }; } function loadState() { try { if (fs.existsSync(STATE_PATH)) { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } } catch (e) { /* silent */ } return {}; } function saveState(updates) { try { fs.mkdirSync(PITH_DIR, { recursive: true }); const current = loadState(); fs.writeFileSync(STATE_PATH, JSON.stringify({ ...current, ...updates }, null, 2)); } catch (e) { /* silent — never block a session */ } } // Per-project state: keyed by a hash of the working directory // so different projects have independent mode/wiki state function projectKey() { const cwd = process.env.CLAUDE_CWD || process.cwd(); return 'proj_' + Buffer.from(cwd).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 20); } function loadProjectState() { const state = loadState(); return state[projectKey()] || {}; } function saveProjectState(updates) { const state = loadState(); const key = projectKey(); state[key] = { ...(state[key] || {}), ...updates }; saveState(state); } // Plugin root — where the pith directory lives // Priority: CLAUDE_PLUGIN_ROOT env → ~/.config/pith/config.json plugin_root → __dirname/.. function pluginRoot() { if (process.env.CLAUDE_PLUGIN_ROOT) return process.env.CLAUDE_PLUGIN_ROOT; for (const p of CONFIG_PATHS) { try { if (fs.existsSync(p)) { const cfg = JSON.parse(fs.readFileSync(p, 'utf8')); if (cfg.plugin_root) return cfg.plugin_root; } } catch (e) { /* silent */ } } return path.join(__dirname, '..'); } module.exports = { loadConfig, loadState, saveState, loadProjectState, saveProjectState, pluginRoot, PITH_DIR, STATE_PATH, DEFAULTS, }; - hooks/post-tool-use.jsRunsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // Pith — PostToolUse hook ← THE CORE INNOVATION // // Runs after every tool call. Compresses tool results before they enter context. // This is the largest source of token waste in a real Claude Code session — // nobody else compresses this layer. // // Savings: 30-50% of total context tokens in a typical session. // // Input (stdin): JSON { tool_name, tool_input, tool_response } // Output (stdout): JSON { output: "compressed" } OR nothing (pass-through) const fs = require('fs'); const path = require('path'); const { loadConfig, loadProjectState, saveProjectState } = require('./config'); const config = loadConfig(); if (!config.tool_compress) process.exit(0); const THRESHOLD = config.tool_compress_threshold || 30; let raw = ''; process.stdin.on('data', c => { raw += c; }); process.stdin.on('end', () => { try { const data = JSON.parse(raw); const toolName = (data.tool_name || data.toolName || '').replace('Tool', ''); const toolInput = data.tool_input || data.toolInput || {}; let result = data.tool_response || data.toolResponse || ''; // Normalize: Claude Code sends tool_response as a structured object, not a plain string. // Read → { type: "text", file: { filePath, content, numLines, startLine, totalLines } } // Bash → { stdout, stderr, interrupted, isImage, noOutputExpected } // Grep → { stdout, stderr, ... } (same as Bash) // Generic fallback: look for content/text/output fields, then JSON.stringify if (typeof result === 'object' && result !== null) { if (result.file && typeof result.file.content === 'string') { // Read tool result = result.file.content; } else if (typeof result.stdout === 'string') { // Bash / Grep result = result.stdout; } else { result = result.content || result.text || result.output || JSON.stringify(result); } } if (typeof result !== 'string') result = String(result); const lines = result.split('\n'); if (lines.length <= THRESHOLD) process.exit(0); // small — pass through let compressed = null; let comprFormat = 'skeleton'; // track which compression path was used switch (toolName.toLowerCase()) { case 'read': case 'readfile': { const resp = data.tool_response || data.toolResponse || {}; const fp = toolInput.file_path || toolInput.path || (resp.file && resp.file.filePath) || ''; compressed = compressFileRead(fp, result, lines); comprFormat = ['json'].includes((fp.split('.').pop() || '').toLowerCase()) ? 'toon' : 'skeleton'; break; } case 'bash': compressed = compressBash(toolInput.command || toolInput.cmd || '', result, lines); comprFormat = 'bash'; break; case 'grep': compressed = compressGrep(toolInput.pattern || '', result, lines); comprFormat = 'grep'; break; case 'webfetch': compressed = compressWeb(toolInput.url || '', result, lines); comprFormat = 'web'; break; } if (compressed !== null) { // Track savings estimate const beforeTokens = Math.ceil(result.length / 4); const afterTokens = Math.ceil(compressed.length / 4); const savedTokens = Math.max(0, beforeTokens - afterTokens); const proj = loadProjectState(); // Route savings into per-format buckets for status breakdown const updates = { tool_savings_session: (proj.tool_savings_session || 0) + savedTokens, tokens_saved_session: (proj.tokens_saved_session || 0) + savedTokens, }; if (comprFormat === 'toon') { updates.toon_savings_session = (proj.toon_savings_session || 0) + savedTokens; updates.toon_savings_total = (proj.toon_savings_total || 0) + savedTokens; } else if (comprFormat === 'skeleton') { updates.skeleton_savings_session = (proj.skeleton_savings_session || 0) + savedTokens; } else if (comprFormat === 'bash') { updates.bash_savings_session = (proj.bash_savings_session || 0) + savedTokens; } else if (comprFormat === 'grep') { updates.grep_savings_session = (proj.grep_savings_session || 0) + savedTokens; } else if (comprFormat === 'web') { updates.web_savings_session = (proj.web_savings_session || 0) + savedTokens; } // ── Offload large results to file ──────────────────────────────────── // If still >offload_threshold tokens after compression, write to // ~/.pith/tmp/ and return a compact pointer. Result never enters context. const OFFLOAD_THRESHOLD = config.offload_threshold || 300; let finalOutput = compressed; let offloadedFile = null; if (afterTokens > OFFLOAD_THRESHOLD) { offloadedFile = offloadResult(toolName, toolInput, compressed, afterTokens); if (offloadedFile) { finalOutput = offloadedFile.pointer; const offloadSaved = afterTokens - Math.ceil(finalOutput.length / 4); updates.offload_savings_session = (proj.offload_savings_session || 0) + offloadSaved; updates.offload_savings_total = (proj.offload_savings_total || 0) + offloadSaved; // Offload savings are additional context reduction on top of compression — // include them in the running total so bucket percentages sum to ≤100% updates.tokens_saved_session = (updates.tokens_saved_session || 0) + offloadSaved; // Log this result as stale-eligible (turn stamped in prompt-submit) const staleList = (proj.stale_results || []).slice(-20); staleList.push({ turn: proj.turn_count || 0, tokens: afterTokens, label: (toolInput.file_path || toolInput.command || toolInput.pattern || toolName).slice(0, 60), file: offloadedFile.filepath, }); updates.stale_results = staleList; - hooks/prompt-submit.jsRunsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // Pith — UserPromptSubmit hook // Parses /pith commands, tracks token estimates, injects per-message context. const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const { loadConfig, loadProjectState, saveProjectState, pluginRoot } = require('./config'); const OUTPUT_MODES = new Set(['lean', 'precise', 'ultra', 'off']); // Subcommands handled entirely by Claude Code skill files — hook just skips them const SKILL_CMDS = new Set(['debug', 'review', 'arch', 'plan', 'commit', 'install', 'uninstall']); // Read actual token counts from the current session's transcript JSONL. // transcript_path may be provided by Claude Code; if not, we derive it from session_id + cwd. function syncTranscriptTokens(data) { try { const os = require('os'); let transcriptPath = data.transcript_path; if (!transcriptPath && data.session_id) { const cwd = data.cwd || process.env.CLAUDE_CWD || process.cwd(); const slug = cwd.replace(/\//g, '-'); const dir = path.join(os.homedir(), '.claude', 'projects', slug); const file = path.join(dir, `${data.session_id}.jsonl`); if (fs.existsSync(file)) transcriptPath = file; } if (!transcriptPath || !fs.existsSync(transcriptPath)) return; // Output: sum all turns (each turn's output is independent) // Input: use ONLY the latest assistant entry — each turn's input already // includes full conversation history, so summing causes massive double-count let outputTokens = 0, latestInputTokens = 0; const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n'); for (const line of lines) { if (!line.trim()) continue; try { const d = JSON.parse(line); if (d.type === 'assistant' && d.message && d.message.usage) { const u = d.message.usage; outputTokens += u.output_tokens || 0; // Overwrite each time — last entry wins (= current context size) latestInputTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); } } catch (_) { /* skip malformed line */ } } if (outputTokens > 0 || latestInputTokens > 0) { saveProjectState({ output_tokens_est: outputTokens, output_tokens_actual: outputTokens, // input = current context window size (latest turn only) ...(latestInputTokens > 0 ? { input_tokens_est: latestInputTokens } : {}), }); } } catch (_) { /* silent — never block a session */ } } // Parse optional --slash <prefix> flag. When set, stdin is treated as the // raw argument text (not JSON), and the prompt is built internally as // `${prefix} ${stdin}`. This lets slash-command .md files pipe user arguments // in via a heredoc instead of embedding them into a shell-quoted JSON string, // which eliminates shell-injection via quotes/$()/backticks in user input. const argv = process.argv.slice(2); let slashPrefix = null; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--slash' && i + 1 < argv.length) { slashPrefix = argv[i + 1]; if (!/^\/[A-Za-z0-9_-]+$/.test(slashPrefix)) { process.stderr.write('[PITH: --slash prefix must match /[A-Za-z0-9_-]+]\n'); process.exit(2); } i++; } } let raw = ''; process.stdin.on('data', c => { raw += c; }); process.stdin.on('end', () => { try { let data; if (slashPrefix) { // stdin is raw user-typed argument text; strip one trailing newline const args = raw.replace(/\n$/, ''); data = { prompt: args ? `${slashPrefix} ${args}` : slashPrefix }; } else { data = JSON.parse(raw); } const prompt = (data.prompt || '').trim(); const lower = prompt.toLowerCase(); const config = loadConfig(); // Sync real output/input token counts from transcript before reading state syncTranscriptTokens(data); const proj = loadProjectState(); const root = pluginRoot(); const out = []; // ── /pith <arg> ──────────────────────────────────────────────────────── if (lower.startsWith('/pith')) { const parts = prompt.trim().split(/\s+/); const arg = (parts[1] || '').toLowerCase(); const rest = parts.slice(2).join(' '); if (OUTPUT_MODES.has(arg)) { saveProjectState({ mode: arg }); out.push(arg === 'off' ? 'PITH OUTPUT COMPRESSION: deactivated.' : modeRules(arg, root)); } else if (arg === '' || arg === 'on') { const m = proj.mode && proj.mode !== 'off' ? proj.mode : (config.default_mode !== 'off' ? config.default_mode : 'lean'); saveProjectState({ mode: m }); out.push(modeRules(m, root)); } else if (arg === 'wiki') { if (rest) { // /pith wiki "question" — query the wiki out.push(wikiQuery(rest, root)); } else { // /pith wiki — toggle wiki mode. // Debounce: UserPromptSubmit hook fires before the slash command // re-calls this script. If the toggle happened within the last 2s, // skip re-toggling and just echo current state. const now = Date.now(); const lastToggle = proj._wiki_toggle_ms || 0; if (now - lastToggle < 2000) { // Already toggled — confirm state without mutating out.push(proj.wiki_mode ? 'PITH WIKI MODE: active. I will maintain the project wiki as we work.\n\n' + wikiModeRules(root) : 'PITH WIKI MODE: deactivated.'); } else { const next = !proj.wiki_mode; saveProjectState({ wiki_mode: next, _wiki_toggle_ms: now }); out.push(next ? 'PITH WIKI MODE: active. I will maintain the project wiki as we work.\n\n' + wikiModeRules(root) : 'PITH WIKI MODE: deactivated.'); } } } else if (arg = - hooks/session-start.jsRunsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // Pith — SessionStart hook // Runs once per session. Responsibilities: // 1. Detect first run → inject onboarding prompt // 2. Cache-optimize CLAUDE.md (suggest stable prefix) // 3. Inject active output-mode rules // 4. Inject wiki-mode rules if wiki mode is on // 5. Inject token budget if set // 6. Announce active automatic features const fs = require('fs'); const path = require('path'); const { loadConfig, loadProjectState, saveProjectState, pluginRoot } = require('./config'); const config = loadConfig(); const proj = loadProjectState(); // Reset per-session counters saveProjectState({ session_start: new Date().toISOString(), tokens_saved_session: 0, // ← was missing — caused phantom 100% savings tool_savings_session: 0, toon_savings_session: 0, skeleton_savings_session: 0, bash_savings_session: 0, offload_savings_session: 0, output_savings_session: 0, grep_savings_session: 0, web_savings_session: 0, compact_count_session: 0, escalation_count_session: 0, hindsight_nudged: false, input_tokens_est: 0, output_tokens_est: 0, turn_count: 0, stale_results: [], compact_nudged: false, }); // ── Clean up tmp files older than 24 h ──────────────────────────────────── try { const os = require('os'); const tmpDir = require('path').join(os.homedir(), '.pith', 'tmp'); const fs = require('fs'); const cutoff = Date.now() - 24 * 60 * 60 * 1000; if (fs.existsSync(tmpDir)) { fs.readdirSync(tmpDir).forEach(f => { const fp = require('path').join(tmpDir, f); try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch (e) { /* skip locked files */ } }); } } catch (e) { /* never block session start */ } const root = pluginRoot(); const output = []; // ── 1. FIRST RUN ──────────────────────────────────────────────────────────── if (!proj.setup_done) { let setupScript = ''; try { setupScript = fs.readFileSync( path.join(root, 'skills', 'pith-setup', 'SKILL.md'), 'utf8' ).replace(/^---[\s\S]*?---\s*/, ''); } catch (e) { setupScript = `On the user's very first message this session, introduce Pith in 2 sentences, then ask: "New project or existing codebase?" Offer to build a project wiki. Be conversational — one question at a time. Do not dump information upfront.`; } process.stdout.write('PITH FIRST RUN\n\n' + setupScript); process.exit(0); } // ── 2. ACTIVE FEATURES ANNOUNCEMENT ───────────────────────────────────────── const features = []; if (config.tool_compress) features.push('tool compression'); if (config.auto_compact) features.push(`auto-compact at ${Math.round(config.auto_compact_threshold * 100)}%`); if (features.length) output.push(`PITH ACTIVE: ${features.join(', ')}.`); // ── 2a. USTYNOV PRINCIPLE (injected once per project) ──────────────────────── // Ustynov [2026] arXiv:2604.07502 — abbreviations save 17% input tokens but // cause re-reads that increase total session cost by 67%. if (!proj.ustynov_injected) { output.push( 'NAMING CONVENTION — Ustynov Principle (2026):\n' + 'Use descriptive names in all generated code.\n' + ' ✓ calculateUserSessionTotal → intent clear, one-shot\n' + ' ✗ calcSessTotal → ambiguous, causes re-reads (+67% cost)\n' + 'This applies to functions, variables, and file names you create or rename.' ); const { saveProjectState: _sps } = require('./config'); _sps({ ustynov_injected: true }); } // ── 3. OUTPUT MODE RULES ───────────────────────────────────────────────────── const mode = proj.mode || config.default_mode; if (mode && mode !== 'off') { let skillContent = ''; try { skillContent = fs.readFileSync( path.join(root, 'skills', 'pith', 'SKILL.md'), 'utf8' ).replace(/^---[\s\S]*?---\s*/, ''); // Filter level table to only the active level row skillContent = skillContent.split('\n').filter(line => { const m = line.match(/^\|\s*\*\*(\w+)\*\*\s*\|/); return !m || m[1] === mode; }).join('\n'); } catch (e) { const fallback = { precise: 'Drop filler/hedging/pleasantries. Full sentences. Professional.', lean: 'Drop articles (a/an/the). Fragments OK. Short synonyms. Drop filler.', ultra: 'Max compression. Abbreviate common terms. Arrows (→). Tables > prose.', }; skillContent = `${fallback[mode] || fallback.lean} Technical terms exact. Code unchanged. ACTIVE EVERY RESPONSE until /pith off.`; } output.push(`OUTPUT MODE: ${mode.toUpperCase()}\n\n${skillContent}`); } // ── 4. WIKI MODE RULES ─────────────────────────────────────────────────────── if (proj.wiki_mode) { try { const wikiContent = fs.readFileSync( path.join(root, 'skills', 'pith-wiki', 'SKILL.md'), 'utf8' ).replace(/^---[\s\S]*?---\s*/, ''); output.push('WIKI MODE ACTIVE\n\n' + wikiContent); } catch (e) { output.push('WIKI MODE ACTIVE. Maintain wiki pages as we work. After decisions/solutions, offer to save to wiki.'); } } // ── 5. TOKEN BUDGET ────────────────────────────────────────────────────────── if (proj.budget) { output.push(`TOKEN BUDGET: ≤${proj.budget} tokens per response. Count as you write. Stop when done. No apology for brevity.`); } // ── 6. CLAUDE.MD CACHE NUDGE ───────────────────────────────────────────────── try { const claudeMd = path.join(process.env.CLAUDE_CWD || process.cwd(), 'CLAUDE.md'); if (fs.existsSync(claudeMd) && !proj.cache_optimized) { const lines = fs.readFileSync(claudeMd, 'utf8').split('\n').length; if (lines > 60) { output.push( `PITH TIP: CLAUDE.md is ${lines} lines and re-read every turn at full cost. ` + `Run \`/pith optimize-cache\` to restructure it for prompt caching and cut that cost by ~80%.` ); } } } catch (e) { /* silent */ } // ── Phase 9: Cache-Lock ─────────────────────────── - hooks/statusline.shGitHub
Read the script
#!/usr/bin/env bash # Pith statusline — fast badge for Claude Code statusline. # Output: "PITH 12k/200k" or "PITH:LEAN 12k/200k" or "PITH:WIKI 12k/200k" # # Single python3 invocation per keypress (down from three). The state path # and project key are passed as argv — never interpolated into `python3 -c` # literals — so unusual $HOME / cwd characters can't break the script. STATE="${HOME}/.pith/state.json" [ ! -f "$STATE" ] && echo "PITH" && exit 0 # Derive project key from cwd. Base64 + strip non-alnum keeps this safe to # pass as argv (no quoting hazard) and reproducible. CWD_KEY=$(printf '%s' "$PWD" | base64 | tr -d '/+=\n' | cut -c1-20) PROJ_KEY="proj_${CWD_KEY}" # Single python call returns a TAB-separated tuple: mode, wiki, tokens, pct. # If anything goes wrong it prints safe defaults so the badge still renders. read -r MODE WIKI TOKENS PCT < <( python3 - "$STATE" "$PROJ_KEY" <<'PY' 2>/dev/null || printf 'off\t0\t0\t0\n' import json, sys try: state_path, key = sys.argv[1], sys.argv[2] with open(state_path) as f: d = json.load(f) proj = d.get(key) or {} mode = proj.get("mode") or "off" wiki = "1" if proj.get("wiki_mode") else "0" tokens = int(proj.get("input_tokens_est", 0) or 0) saved = int(proj.get("tool_savings_session", 0) or 0) total = tokens + saved pct = round(saved / total * 100) if total > 0 else 0 print(f"{mode}\t{wiki}\t{tokens}\t{pct}") except Exception: print("off\t0\t0\t0") PY ) # Defaults for read's output (if the here-doc produced nothing at all). MODE="${MODE:-off}" WIKI="${WIKI:-0}" TOKENS="${TOKENS:-0}" PCT="${PCT:-0}" # Format token count (e.g. 12345 → 12k). if [ "${TOKENS}" -ge 1000 ] 2>/dev/null; then TOKENS_FMT="$(( TOKENS / 1000 ))k" else TOKENS_FMT="${TOKENS}" fi # Build badge suffix: wiki > mode > nothing. if [ "$WIKI" = "1" ]; then SUFFIX=":WIKI" elif [ "$MODE" != "off" ] && [ -n "$MODE" ]; then SUFFIX=":$(printf '%s' "$MODE" | tr '[:lower:]' '[:upper:]')" else SUFFIX="" fi if [ "${PCT}" -gt 0 ] 2>/dev/null; then echo "PITH${SUFFIX} ↓${PCT}% ${TOKENS_FMT}/200k" else echo "PITH${SUFFIX} ${TOKENS_FMT}/200k" fi - hooks/stop.jsRunsGitHub
Read the script
#!/usr/bin/env node 'use strict'; // Pith — Stop hook // Runs when Claude finishes a response. // Reads transcript JSONL for exact usage counts; falls back to response-length estimate. const fs = require('fs'); const { loadProjectState, saveProjectState } = require('./config'); // Pricing per 1M tokens — [input, output] const PRICING = { 'opus-4-7': [5.0, 25.0], 'opus-4-6': [5.0, 25.0], 'opus-4-5': [5.0, 25.0], 'opus-4-1': [15.0, 75.0], 'opus-4': [15.0, 75.0], 'sonnet-4-6': [3.0, 15.0], 'sonnet-4-5': [3.0, 15.0], 'sonnet-4': [3.0, 15.0], 'sonnet-3-7': [3.0, 15.0], 'haiku-4-5': [1.0, 5.0], 'haiku-3-5': [0.8, 4.0], 'opus-3': [15.0, 75.0], 'haiku-3': [0.25, 1.25], }; function getPricing(model) { if (!model) return [3.0, 15.0]; const m = model.toLowerCase().replace('claude-', '').replace(/_/g, '-'); const keys = Object.keys(PRICING).sort((a, b) => b.length - a.length); for (const key of keys) { if (m.includes(key)) return PRICING[key]; } return [3.0, 15.0]; } // Read token counts and model from transcript JSONL. // output = sum of all turns (each turn's output is independent) // input = latest assistant entry only (each turn's input includes full history → summing double-counts) function readTranscriptTokens(transcriptPath) { let outputTokens = 0, latestInputTokens = 0, latestModel = null; try { const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n'); for (const line of lines) { if (!line.trim()) continue; try { const d = JSON.parse(line); if (d.type === 'assistant' && d.message && d.message.usage) { const u = d.message.usage; outputTokens += u.output_tokens || 0; latestInputTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); if (d.message.model) latestModel = d.message.model; } } catch (_) { /* skip malformed line */ } } } catch (_) { /* file unreadable — caller falls back */ } return { outputTokens, inputTokens: latestInputTokens, model: latestModel }; } let raw = ''; process.stdin.on('data', c => { raw += c; }); process.stdin.on('end', () => { try { const data = JSON.parse(raw); const proj = loadProjectState(); const updates = {}; // ── Token counts: transcript > data.usage > response-length estimate ── let actualOut = 0; if (data.transcript_path) { const { outputTokens, inputTokens, model } = readTranscriptTokens(data.transcript_path); if (outputTokens > 0 || inputTokens > 0) { actualOut = outputTokens; updates.output_tokens_est = outputTokens; updates.input_tokens_est = inputTokens; updates.output_tokens_actual = outputTokens; updates.input_tokens_actual = inputTokens; } if (model) updates.model = model; } if (actualOut === 0 && data.usage) { updates.input_tokens_actual = (proj.input_tokens_actual || 0) + (data.usage.input_tokens || 0); updates.output_tokens_actual = (proj.output_tokens_actual || 0) + (data.usage.output_tokens || 0); actualOut = data.usage.output_tokens || 0; updates.input_tokens_est = updates.input_tokens_actual; } if (actualOut === 0 && data.response) { // Last-resort estimate from response text length actualOut = Math.ceil(String(data.response).length / 4); updates.output_tokens_est = (proj.output_tokens_est || 0) + actualOut; updates.input_tokens_est = (proj.input_tokens_est || 0) + actualOut; } // ── Output savings from active compression mode ─────────────────────── // stop.js fires once per response. actualOut = cumulative session total from transcript. // Must compute savings on DELTA only (new output this turn) to avoid compounding. // deltaOut = cumulative now minus cumulative at last stop event. const [IN_COST_PER_M, OUT_COST_PER_M] = getPricing(updates.model || proj.model || null); if (actualOut > 0) { const prevOut = proj.output_tokens_last_stop || 0; const deltaOut = Math.max(0, actualOut - prevOut); updates.output_tokens_last_stop = actualOut; // Track turn count for per-response averages updates.turn_count_session = (proj.turn_count_session || 0) + 1; const mode = proj.mode || 'off'; const rate = mode === 'ultra' ? 0.42 : mode === 'lean' ? 0.25 : mode === 'precise' ? 0.12 : 0; if (rate > 0 && deltaOut > 0) { const baseline = Math.ceil(deltaOut / (1 - rate)); const outSaved = baseline - deltaOut; updates.output_savings_session = (proj.output_savings_session || 0) + outSaved; } } // Accumulate lifetime totals const sessionSaved = proj.tokens_saved_session || 0; updates.tokens_saved_total = (proj.tokens_saved_total || 0) + sessionSaved; // Lifetime cost saved — split by token type const outSavedSession = updates.output_savings_session || proj.output_savings_session || 0; const toolSaved = Math.max(0, sessionSaved - outSavedSession); const sessionCostSaved = (toolSaved / 1_000_000 * IN_COST_PER_M) + (outSavedSession / 1_000_000 * OUT_COST_PER_M); updates.cost_saved_total = (proj.cost_saved_total || 0) + sessionCostSaved; saveProjectState(updates); } catch (e) { /* silent */ } process.exit(0); });
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Status: stable — not actively adding features. Bug fixes welcome via issues. Token compression hooks for Claude Code. Install once, works in every session, zero config.
Repo: abhisekjha/pith

