Read the script
#!/usr/bin/env node
/**
* Toonify PostToolUse Hook
*
* Intercepts tool results from Read, Grep, Glob, WebFetch, Bash and:
* - Converts structured data (JSON/YAML) to TOON format
* - Collapses repetitive debug output (test failures, stack traces, diagnostics)
*
* Source code is NOT compressed: the comment-stripping heuristics truncated
* regex literals such as /^https?:\/\// at the `//`, producing code that no
* longer parses. See src/optimizer/compressors/code.ts for the full rationale.
*
* CSV is not converted: TOON measured as a net loss on CSV at every size.
*
* tool_response is a structured object for the tools this hook matches, not
* a plain string — see extractText() below for the shapes verified (Read,
* WebFetch, Grep's 'content'/'count' modes, and Bash's combined `stdout`
* field, each per the tool's own schema).
*
* Compressed content ALWAYS goes out via hookSpecificOutput.updatedToolOutput,
* which REPLACES what Claude sees — that is what actually shrinks context.
* The hook never uses additionalContext: it APPENDS (verified live that
* `suppressOutput` doesn't hide the original), so it would grow total context
* instead of shrinking it. For an object tool_response the replacement is the
* object with its text field swapped; for a (compat-only) string
* tool_response it's the compressed string itself. Where tool_response is an
* OBJECT with no compressible field for that tool (Grep's default
* 'files_with_matches' mode, Glob, an unmatched tool), the hook passes
* through untouched — there is nothing to safely replace.
*
* Input: JSON on stdin with { tool_name, tool_response, ... }
* Output: JSON on stdout with { continue, hookSpecificOutput }
*/
import { encode as toonEncode } from '@toon-format/toon';
import { parse as yamlParse } from 'yaml';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
// --- Configuration ---
/**
* Maximum content size to attempt JSON.parse/yamlParse/detection on (10 MB).
* Mirrors MAX_CONTENT_SIZE in src/optimizer/token-optimizer.ts — this hook
* has its own detection pipeline (see detectStructuredData/detectDebugOutput
* below) and needs the same DoS-prevention ceiling; without it, arbitrarily
* large Read/WebFetch content gets parsed synchronously with no bound.
*/
const MAX_CONTENT_SIZE = 10 * 1024 * 1024;
const DEFAULT_CONFIG = {
enabled: true,
minTokensThreshold: 50,
minSavingsThreshold: 30,
// Write/Edit are mutations — their result (success flag / diff) isn't
// compressible content and altering it could mislead Claude. Bash is NOT
// skipped: its stdout is a large token sink (curl/jq JSON, test-runner and
// build logs) that the same selective detect→compress path handles safely
// (see the Bash branch in extractText). Small command output stays below
// the token threshold and passes through untouched.
skipToolPatterns: ['Write', 'Edit'],
};
function loadConfig() {
const configPath = join(homedir(), '.claude', 'toonify-config.json');
let fileConfig = {};
if (existsSync(configPath)) {
try {
fileConfig = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
// Ignore invalid config
}
}
// Environment variable overrides
const envConfig = {};
if (process.env.TOONIFY_ENABLED !== undefined) {
envConfig.enabled = process.env.TOONIFY_ENABLED !== 'false';
}
if (process.env.TOONIFY_MIN_TOKENS) {
envConfig.minTokensThreshold = parseInt(process.env.TOONIFY_MIN_TOKENS, 10);
}
if (process.env.TOONIFY_MIN_SAVINGS) {
envConfig.minSavingsThreshold = parseInt(process.env.TOONIFY_MIN_SAVINGS, 10);
}
if (process.env.TOONIFY_SKIP_TOOLS) {
envConfig.skipToolPatterns = process.env.TOONIFY_SKIP_TOOLS.split(',');
}
return { ...DEFAULT_CONFIG, ...fileConfig, ...envConfig };
}
// --- Structured Data Detection ---
function detectStructuredData(content) {
// Try JSON
try {
const data = JSON.parse(content);
if (typeof data === 'object' && data !== null) {
return { type: 'json', data };
}
} catch {
// Not JSON
}
// Try YAML — gated on STRUCTURE (looksLikeYAML), then accept any non-null
// object. The old gate (`Object.keys(data).length > 1`) silently skipped the
// most valuable and most common YAML shape: a single top-level key holding a
// large uniform list (`services:`, a k8s `spec:`, `data:`), which is exactly
// what TOON compresses best. The structural density check (looksLikeYAML) is
// what keeps prose out, so the key-count gate isn't needed for that job.
// Mirrors Detector.tryYAML / looksLikeYAML in src/optimizer/pipeline/detector.ts.
if (looksLikeYAML(content)) {
try {
const data = yamlParse(content);
if (typeof data === 'object' && data !== null) {
return { type: 'yaml', data };
}
} catch {
// Not YAML
}
}
return null;
}
// Structural YAML heuristic — density of `key: value` / list / indented lines.
// This is what keeps prose (which yaml.parse would happily coerce into some
// object) from being misdetected, so detectStructuredData can drop the old
// top-level-key-count gate. Ported from looksLikeYAML in
// src/optimizer/pipeline/detector.ts — keep the two in sync. Each scanned line
// is length-capped first (capLine), matching the hook's ReDoS discipline for
// every other line-scanning predicate here.
function looksLikeYAML(content) {
const lines = content.split('\n').filter(l => l.trim());
if (lines.length < 3) return false;
const yamlLinePattern = /^\s*[\w][\w\s.-]*:\s*.+/;
const listItemPattern = /^\s*-\s+/;
const indentedPattern = /^\s{2,}\S/;
let yamlLines = 0;
let listItems = 0;
let indented = 0;
for (const raw of lines.slice(0, 20)) {
const line = capLine(raw);
if (yamlLinePattern.test(line)) yamlLines++;
if (listItemPattern.test(line)) listItems++;
if (indentedPattern.test(line)) indented++;
}
const hasStructure = (