Skip to content
Development
Hook

Hooks

What pith runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
pith
9812 skills4 commands4 hooks
Install
> /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
Read hooks/hooks.json

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.

Ships withpith

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.

Get the whole plugin
Stats
98
Stars
11
Forks
Maintained
Maintenance
Python
Language
MIT
License
4mo ago
Last commit
5mo ago
Created

Repo: abhisekjha/pith