diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index a7c39e4..0423e56 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -1,20 +1,22 @@ # Supercompact — Claude Code Plugin -Entity-preservation conversation compaction for Claude Code. Replaces the built-in LLM-based `/compact` with EITF scoring — **~400x faster** and **2x better entity retention**. +Entity-preservation conversation compaction for Claude Code. **~400x faster** and **2x better entity retention** than the built-in LLM-based `/compact`. ## Quick Install ```bash -git clone https://github.com/yourusername/supercompact.git +git clone https://github.com/heiervang-technologies/supercompact.git cd supercompact/plugins/claude-code ./install.sh ``` -**Prerequisites:** Python 3.11+, [uv](https://github.com/astral-sh/uv) +The installer automatically registers the plugin in `~/.claude/settings.json`. Restart Claude Code, then use `/supercompact`. + +**Prerequisites:** Python 3.11+, [uv](https://github.com/astral-sh/uv), jq ## What It Does -When Claude Code compacts your conversation (either automatically or via `/compact`), it normally calls an LLM to summarize — slow (~30s) and lossy. Supercompact replaces this with **EITF** (Entity-frequency Inverse Turn Frequency), a zero-model algorithm that: +When Claude Code compacts your conversation (either automatically or via `/compact`), it normally calls an LLM to summarize — slow (~30s) and lossy. Supercompact uses **EITF** (Entity-frequency Inverse Turn Frequency), a zero-model algorithm that: 1. Extracts structured entities (file paths, errors, functions, URLs, etc.) 2. Scores each conversation turn by entity importance × rarity @@ -25,13 +27,13 @@ Result: compaction in **~0.2 seconds** with **2x better retention** of file path ## How It Works -The installer sets up three integration points: +The plugin provides three integration points: -1. **cli.js patch** — Replaces the LLM API call in Claude Code's main compaction function with a subprocess call to supercompact. Falls back to the original LLM on error. +1. **`/supercompact` command** — On-demand compaction. Replaces the session with a compacted version and restarts. This is the primary interface. -2. **PreCompact hook** — Backs up the full transcript before any compaction runs, and produces a supercompact alternative alongside Claude's built-in result. +2. **PreCompact hook** — When Claude's built-in compaction triggers, the hook backs up the full transcript before it's lost. The backup is saved as `*.pre-compact-full` alongside the session JSONL. -3. **`/supercompact` command** — Manual on-demand compaction with configurable method and budget. +3. **cli.js patch** *(npm installations only)* — Replaces the LLM API call in Claude Code's compaction function with supercompact. Falls back to the original LLM on error. Not available on standalone binary installations. ## Configuration @@ -70,6 +72,16 @@ Manual compaction. Examples: ./install.sh --patch-only # Patch cli.js only (plugin must be installed first) ``` +## Update + +```bash +cd supercompact +git pull +./plugins/claude-code/install.sh +``` + +Re-running the installer is safe — it replaces all files and is fully idempotent. + ## Uninstall ```bash @@ -95,9 +107,10 @@ Manual compaction. Examples: ├── hooks/ │ └── hooks.json # PreCompact hook registration ├── hooks-handlers/ - │ └── supercompact-precompact.sh + │ └── supercompact-precompact.sh # Backup-only hook └── scripts/ - ├── patcher.py # cli.js patching logic + ├── compact-session.sh # Main compaction script + ├── patcher.py # cli.js patching logic └── patch-compaction.sh ``` @@ -105,6 +118,15 @@ Manual compaction. Examples: Hook activity is logged to `~/.cache/supercompact/hook.log`. +## Standalone Binary Installation + +If Claude Code is installed as a standalone binary (not via npm), the cli.js patch cannot be applied. The installer detects this automatically, skips patching, and configures `settings.json` for you. + +In standalone mode: +- **`/supercompact`** — Works fully. This is the primary way to compact. +- **`/compact`** — Still uses Claude's built-in LLM compaction (cannot be replaced without cli.js patch). +- **PreCompact hook** — Backs up the full transcript before Claude's built-in compaction runs. + ## Troubleshooting **Compaction not working after Claude Code update:** @@ -118,7 +140,7 @@ Check `~/.cache/supercompact/hook.log` for errors. Common causes: - Python/uv not in PATH during compaction - Supercompact directory removed or corrupted -**Verify patch status:** +**Verify patch status (npm installations only):** ```bash grep -c "SUPERCOMPACT_EITF" "$(readlink -f "$(which claude)" | sed 's|[^/]*$|cli.js|')" # 1 = patched, 0 = not patched diff --git a/plugins/claude-code/commands/supercompact.md b/plugins/claude-code/commands/supercompact.md index 5785357..01d6154 100644 --- a/plugins/claude-code/commands/supercompact.md +++ b/plugins/claude-code/commands/supercompact.md @@ -1,73 +1,26 @@ --- description: EITF entity-preservation compaction (~400x faster than /compact, 2x better entity retention) argument-hint: "[budget] [--method eitf|setcover|dedup]" -allowed-tools: Bash(cd *), Bash(uv *), Bash(PROJECT_DIR*), Bash(JSONL_FILE*), Bash(ls *), Bash(wc *), Bash(cp *), Bash(mv *), Bash(restart-claude*), Bash(echo *) +allowed-tools: Bash(*/compact-session.sh*), Bash(restart-claude*) --- # Supercompact — Entity-Preservation Compaction -**CRITICAL: Do NOT use the built-in /compact command. You must follow the exact steps below using Bash tool calls.** - -You are running the supercompact algorithm. This is completely separate from Claude Code's built-in /compact. You must execute the bash commands below, not delegate to any built-in compaction. - -## Configuration - -Settings come from environment variables (set via plugin config): -- `PLUGIN_SETTING_METHOD` — scoring method (default: `eitf`). Options: `eitf`, `setcover`, `dedup` -- `PLUGIN_SETTING_BUDGET` — token budget (default: `80000`) - -The user can override these via arguments: `/supercompact 120000 --method setcover` - -## Step 1: Find the conversation JSONL and supercompact directory +Run the compaction script. It will find the session JSONL automatically, compact it, and report results. ```bash -PROJECT_DIR=$(echo "$PWD" | sed 's|/|-|g; s|^|'"$HOME"'/.claude/projects/|') -JSONL_FILE=$(ls -t "$PROJECT_DIR"/*.jsonl 2>/dev/null | head -1) -echo "JSONL: $JSONL_FILE" -wc -l "$JSONL_FILE" +SCRIPT="${CLAUDE_PLUGIN_ROOT:-${HOME}/.local/share/supercompact/claude-code/plugin}/scripts/compact-session.sh" +"$SCRIPT" $ARGUMENTS ``` -## Step 2: Run compaction - -Parse $ARGUMENTS for an optional numeric budget and `--method `. Fall back to env vars, then defaults. +If the script succeeds and reports compaction was performed (not "already within budget"), restart to load the compacted context: ```bash -METHOD="${PLUGIN_SETTING_METHOD:-eitf}" -BUDGET="${PLUGIN_SETTING_BUDGET:-80000}" -# Override from arguments if provided (e.g. "/supercompact 120000 --method setcover") -for arg in $ARGUMENTS; do - if [[ "$arg" =~ ^[0-9]+$ ]]; then BUDGET="$arg"; fi - if [[ "$prev" == "--method" ]]; then METHOD="$arg"; fi - prev="$arg" -done -# Find supercompact installation -SUPERCOMPACT_DIR="$HOME/.local/share/supercompact/claude-code/supercompact" -if [[ ! -f "$SUPERCOMPACT_DIR/compact.py" ]]; then - echo "ERROR: supercompact not found at $SUPERCOMPACT_DIR. Run install.sh first." - exit 1 -fi -echo "Method: $METHOD, Budget: $BUDGET" -cd "$SUPERCOMPACT_DIR" && uv run python compact.py "$JSONL_FILE" --method "$METHOD" --budget "$BUDGET" --output /tmp/supercompact-output.jsonl --verbose +restart-claude "Session compacted with supercompact. Restarting to load compacted context." ``` -## Step 3: Replace the session JSONL - -```bash -cp "$JSONL_FILE" "${JSONL_FILE}.pre-supercompact" -mv /tmp/supercompact-output.jsonl "$JSONL_FILE" -echo "Replaced session JSONL (backup: ${JSONL_FILE}.pre-supercompact)" -``` - -## Step 4: Report results briefly - -Report: method used, turns kept vs dropped, compression ratio, wall clock time. - -## Step 5: Restart to reload compacted context - -The JSONL on disk is now compacted, but the live session still has old context in memory. Restart to load the compacted version: +If `restart-claude` is not available, tell the user: "Run `/quit` then `claude --resume` to load the compacted context." -```bash -restart-claude "Session compacted with supercompact ($METHOD). Restarting to load compacted context." -``` +If the script reports "already within budget", tell the user and do NOT restart. -If `restart-claude` is not available, tell the user: "Run `/quit` then `claude --resume` to load the compacted context." +If the script fails, show the error output to the user and do not restart. diff --git a/plugins/claude-code/hooks-handlers/supercompact-precompact.sh b/plugins/claude-code/hooks-handlers/supercompact-precompact.sh index d0c6dd4..f4ff67b 100755 --- a/plugins/claude-code/hooks-handlers/supercompact-precompact.sh +++ b/plugins/claude-code/hooks-handlers/supercompact-precompact.sh @@ -1,42 +1,18 @@ #!/usr/bin/env bash -# supercompact-precompact.sh - PreCompact hook for entity-preservation compaction +# supercompact-precompact.sh - PreCompact hook (backup-only) # -# Triggered when Claude Code is about to compact the conversation. # The PreCompact hook CANNOT block or replace Claude's built-in compaction — -# it is notification-only. So we use it to: +# it is notification-only. Running supercompact here is wasted work since +# Claude's LLM compaction overwrites the result anyway. # +# Instead, we just: # 1. Back up the full transcript before Claude's summarization loses detail -# 2. Run compaction (configurable method) to produce a superior alternative -# 3. The user can later resume from the supercompact version instead of Claude's -# -# Configuration via environment variables: -# PLUGIN_SETTING_METHOD Scoring method (default: eitf) -# PLUGIN_SETTING_BUDGET Token budget (default: 80000) +# 2. Log the event +# 3. Clean up old backups set -euo pipefail -# Resolve supercompact installation root -# Layout: ~/.local/share/supercompact/claude-code/plugin/hooks-handlers/THIS_SCRIPT -# ~/.local/share/supercompact/claude-code/supercompact/compact.py -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -INSTALL_ROOT="$(cd "${PLUGIN_ROOT}/.." && pwd)" -SUPERCOMPACT_DIR="${INSTALL_ROOT}/supercompact" - -if [[ ! -f "${SUPERCOMPACT_DIR}/compact.py" ]]; then - # Fallback: check if supercompact is bundled inside the plugin dir (dev mode) - if [[ -f "${PLUGIN_ROOT}/supercompact/compact.py" ]]; then - SUPERCOMPACT_DIR="${PLUGIN_ROOT}/supercompact" - else - echo "$(date -Iseconds) ERROR: supercompact not found at ${SUPERCOMPACT_DIR}" >> "${HOME}/.cache/supercompact/hook.log" 2>/dev/null - exit 0 - fi -fi - -METHOD="${PLUGIN_SETTING_METHOD:-eitf}" -BUDGET="${PLUGIN_SETTING_BUDGET:-80000}" LOG_DIR="${HOME}/.cache/supercompact" - mkdir -p "${LOG_DIR}" # Read hook input from stdin (JSON with transcript_path, session_id, trigger, etc.) @@ -48,42 +24,22 @@ JSONL_FILE=$(echo "${HOOK_INPUT}" | jq -r '.transcript_path // empty') echo "$(date -Iseconds) PreCompact hook triggered (trigger=${TRIGGER})" >> "${LOG_DIR}/hook.log" if [[ -z "${JSONL_FILE}" || ! -f "${JSONL_FILE}" ]]; then - echo "$(date -Iseconds) ERROR: No transcript_path in hook input or file missing" >> "${LOG_DIR}/hook.log" - exit 0 + echo "$(date -Iseconds) ERROR: No transcript_path in hook input or file missing" >> "${LOG_DIR}/hook.log" + exit 0 fi JSONL_SIZE=$(wc -l < "${JSONL_FILE}") echo "$(date -Iseconds) Transcript: ${JSONL_FILE} (${JSONL_SIZE} lines)" >> "${LOG_DIR}/hook.log" -# 1. Back up the full transcript before Claude's compaction destroys detail +# Back up the full transcript before Claude's compaction destroys detail BACKUP_FILE="${JSONL_FILE}.pre-compact-full" cp "${JSONL_FILE}" "${BACKUP_FILE}" echo "$(date -Iseconds) Full backup saved: ${BACKUP_FILE}" >> "${LOG_DIR}/hook.log" -# 2. Run supercompact to produce a superior alternative -SC_OUTPUT="${JSONL_FILE}.supercompact" - -echo "$(date -Iseconds) Running supercompact (method=${METHOD}, budget=${BUDGET})" >> "${LOG_DIR}/hook.log" - -cd "${SUPERCOMPACT_DIR}" -if uv run python compact.py "${JSONL_FILE}" \ - --method "${METHOD}" \ - --budget "${BUDGET}" \ - --output "${SC_OUTPUT}" 2>> "${LOG_DIR}/hook.log"; then +# Clean up old backups (keep last 3) +ls -t "${JSONL_FILE}.pre-compact-full"* 2>/dev/null | tail -n +4 | xargs rm -f 2>/dev/null || true +ls -t "${JSONL_FILE}.pre-supercompact"* 2>/dev/null | tail -n +4 | xargs rm -f 2>/dev/null || true - SC_SIZE=$(wc -l < "${SC_OUTPUT}") - echo "$(date -Iseconds) Supercompact (${METHOD}): ${JSONL_SIZE} -> ${SC_SIZE} lines (saved as .supercompact)" >> "${LOG_DIR}/hook.log" - - # Clean up old backups (keep last 3 of each type) - ls -t "${JSONL_FILE}.pre-compact-full"* 2>/dev/null | tail -n +4 | xargs rm -f 2>/dev/null || true - ls -t "${JSONL_FILE}.supercompact"* 2>/dev/null | tail -n +4 | xargs rm -f 2>/dev/null || true - - echo "$(date -Iseconds) SUCCESS: Supercompact alternative ready at ${SC_OUTPUT}" >> "${LOG_DIR}/hook.log" - echo "$(date -Iseconds) NOTE: Claude's built-in compaction will still run (hook cannot block it)" >> "${LOG_DIR}/hook.log" - echo "$(date -Iseconds) To use supercompact version: cp '${SC_OUTPUT}' '${JSONL_FILE}'" >> "${LOG_DIR}/hook.log" -else - echo "$(date -Iseconds) ERROR: Supercompact (${METHOD}) failed (Claude's compaction will proceed)" >> "${LOG_DIR}/hook.log" - rm -f "${SC_OUTPUT}" 2>/dev/null || true -fi +echo "$(date -Iseconds) Backup-only hook complete (use /supercompact for manual compaction)" >> "${LOG_DIR}/hook.log" exit 0 diff --git a/plugins/claude-code/install.sh b/plugins/claude-code/install.sh index a361c7f..4369801 100755 --- a/plugins/claude-code/install.sh +++ b/plugins/claude-code/install.sh @@ -168,19 +168,39 @@ if [[ "$DO_INSTALL" == true ]]; then ok "Plugin installed to ${INSTALL_DIR}" - # Print plugin-dir usage - echo "" - info "To load the plugin, use one of:" - echo " claude --plugin-dir ${PLUGIN_DEST}" - echo "" - echo " Or add to ~/.claude/settings.json:" - echo " { \"pluginDirs\": [\"${PLUGIN_DEST}\"] }" - echo "" + # Auto-configure settings.json to load the plugin + SETTINGS_FILE="${HOME}/.claude/settings.json" + info "Configuring Claude Code to load plugin..." + mkdir -p "$(dirname "${SETTINGS_FILE}")" + + if [[ ! -f "${SETTINGS_FILE}" ]]; then + # Create settings.json with pluginDirs + echo '{"pluginDirs":["'"${PLUGIN_DEST}"'"]}' | jq . > "${SETTINGS_FILE}" + ok "Created ${SETTINGS_FILE} with pluginDirs" + elif jq -e '.pluginDirs' "${SETTINGS_FILE}" >/dev/null 2>&1; then + # pluginDirs exists — check if our path is already there + if jq -e --arg p "${PLUGIN_DEST}" '.pluginDirs | index($p)' "${SETTINGS_FILE}" >/dev/null 2>&1; then + ok "Plugin already registered in settings.json" + else + # Add our path to existing pluginDirs array + jq --arg p "${PLUGIN_DEST}" '.pluginDirs += [$p]' "${SETTINGS_FILE}" > "${SETTINGS_FILE}.tmp" \ + && mv "${SETTINGS_FILE}.tmp" "${SETTINGS_FILE}" + ok "Added plugin to existing pluginDirs in settings.json" + fi + else + # settings.json exists but no pluginDirs key — add it + jq --arg p "${PLUGIN_DEST}" '. + {pluginDirs: [$p]}' "${SETTINGS_FILE}" > "${SETTINGS_FILE}.tmp" \ + && mv "${SETTINGS_FILE}.tmp" "${SETTINGS_FILE}" + ok "Added pluginDirs to settings.json" + fi fi # ------------------------------------------------------------------ # Patch cli.js # ------------------------------------------------------------------ +PATCH_APPLIED=false +STANDALONE_BINARY=false + if [[ "$DO_PATCH" == true ]]; then SUPERCOMPACT_DEST="${INSTALL_DIR}/supercompact" @@ -188,16 +208,33 @@ if [[ "$DO_PATCH" == true ]]; then fatal "Supercompact not installed at ${SUPERCOMPACT_DEST}. Run install first (without --patch-only)." fi - echo "" - info "Patching Claude Code cli.js..." - bash "${INSTALL_DIR}/plugin/scripts/patch-compaction.sh" "${SUPERCOMPACT_DEST}" - EXIT_CODE=$? + # Detect standalone binary vs npm installation + CLAUDE_BIN="${CLAUDE_BIN:-$(which claude 2>/dev/null || echo "")}" + CLAUDE_REAL="" + if [[ -n "$CLAUDE_BIN" ]]; then + CLAUDE_REAL="$(readlink -f "$CLAUDE_BIN" 2>/dev/null || echo "$CLAUDE_BIN")" + fi - if [[ $EXIT_CODE -eq 0 ]]; then - ok "cli.js patched — compaction now uses supercompact" + if [[ -n "$CLAUDE_REAL" ]] && head -c 4 "$CLAUDE_REAL" 2>/dev/null | grep -q "ELF\|MZ"; then + STANDALONE_BINARY=true + echo "" + warn "Claude Code is installed as a standalone binary (not via npm)" + warn "cli.js patching is not available for standalone installations" + info "The /supercompact slash command and PreCompact hook will still work" + info "Use '/supercompact' for on-demand compaction" else - err "Patching failed (exit code $EXIT_CODE)" - exit $EXIT_CODE + echo "" + info "Patching Claude Code cli.js..." + bash "${INSTALL_DIR}/plugin/scripts/patch-compaction.sh" "${SUPERCOMPACT_DEST}" + EXIT_CODE=$? + + if [[ $EXIT_CODE -eq 0 ]]; then + ok "cli.js patched — compaction now uses supercompact" + PATCH_APPLIED=true + else + warn "cli.js patching failed (exit code $EXIT_CODE)" + warn "The /supercompact slash command and PreCompact hook will still work" + fi fi fi @@ -210,15 +247,19 @@ echo "" echo "What's installed:" echo " • Supercompact library at ${INSTALL_DIR}/supercompact/" echo " • Plugin at ${INSTALL_DIR}/plugin/" -if [[ "$DO_PATCH" == true ]]; then +echo " • Plugin registered in ~/.claude/settings.json" +if [[ "$PATCH_APPLIED" == true ]]; then echo " • cli.js patched for automatic compaction replacement" fi echo "" -echo "Configuration (via environment variables or plugin settings):" -echo " PLUGIN_SETTING_METHOD=eitf # eitf, setcover, dedup" -echo " PLUGIN_SETTING_BUDGET=80000 # token budget" -echo " PLUGIN_SETTING_FALLBACK_TO_BUILTIN=true # fall back to LLM on error" -echo "" -if [[ "$DO_PATCH" == true ]]; then - echo "Restart Claude Code to activate the patch." +echo "Usage:" +if [[ "$PATCH_APPLIED" == true ]]; then + echo " /compact and /supercompact both use supercompact now." + echo " Restart Claude Code to activate." +else + echo " /supercompact # On-demand entity-preservation compaction" + echo " /supercompact 120000 # Custom token budget" fi +echo "" +echo "To update later: git pull && ./install.sh" +echo "To uninstall: ./uninstall.sh" diff --git a/plugins/claude-code/scripts/compact-session.sh b/plugins/claude-code/scripts/compact-session.sh new file mode 100755 index 0000000..8432872 --- /dev/null +++ b/plugins/claude-code/scripts/compact-session.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# compact-session.sh - Self-contained supercompact session compaction +# +# Finds the current Claude Code session JSONL, runs supercompact, +# backs up the original, and replaces it with the compacted version. +# +# Usage: compact-session.sh [budget] [--method name] +# +# Environment: +# CLAUDE_PROJECT_DIR Project dir set by Claude Code (preferred for JSONL lookup) +# PLUGIN_SETTING_METHOD Scoring method (default: eitf) +# PLUGIN_SETTING_BUDGET Token budget (default: 80000) + +set -euo pipefail + +# --- Resolve supercompact installation --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +INSTALL_ROOT="$(cd "${PLUGIN_ROOT}/.." && pwd)" +SUPERCOMPACT_DIR="${INSTALL_ROOT}/supercompact" + +if [[ ! -f "${SUPERCOMPACT_DIR}/compact.py" ]]; then + # Dev mode: repo layout + if [[ -f "${PLUGIN_ROOT}/../../supercompact/compact.py" ]]; then + SUPERCOMPACT_DIR="$(cd "${PLUGIN_ROOT}/../../supercompact" && pwd)" + else + echo "ERROR: supercompact not found at ${SUPERCOMPACT_DIR}" + exit 1 + fi +fi + +# --- Parse arguments --- +METHOD="${PLUGIN_SETTING_METHOD:-eitf}" +BUDGET="${PLUGIN_SETTING_BUDGET:-80000}" +prev="" +for arg in "$@"; do + if [[ "$arg" =~ ^[0-9]+$ ]]; then BUDGET="$arg"; fi + if [[ "$prev" == "--method" ]]; then METHOD="$arg"; fi + prev="$arg" +done + +# --- Find the session JSONL --- +find_project_dir() { + # Prefer CLAUDE_PROJECT_DIR if set + if [[ -n "${CLAUDE_PROJECT_DIR:-}" && -d "${CLAUDE_PROJECT_DIR}" ]]; then + echo "${CLAUDE_PROJECT_DIR}" + return + fi + # Fallback: derive from PWD (same logic Claude Code uses) + local derived + derived="${HOME}/.claude/projects/$(echo "${PWD}" | sed 's|/|-|g')" + if [[ -d "${derived}" ]]; then + echo "${derived}" + return + fi + echo "" +} + +PROJECT_DIR="$(find_project_dir)" +if [[ -z "${PROJECT_DIR}" ]]; then + echo "ERROR: Could not find Claude project directory" + echo " Tried CLAUDE_PROJECT_DIR=${CLAUDE_PROJECT_DIR:-}" + echo " Tried PWD-derived=${HOME}/.claude/projects/$(echo "${PWD}" | sed 's|/|-|g')" + exit 1 +fi + +JSONL_FILE="$(ls -t "${PROJECT_DIR}"/*.jsonl 2>/dev/null | head -1)" +if [[ -z "${JSONL_FILE}" || ! -f "${JSONL_FILE}" ]]; then + echo "ERROR: No .jsonl files found in ${PROJECT_DIR}" + exit 1 +fi + +LINES_BEFORE=$(wc -l < "${JSONL_FILE}") +echo "Session JSONL: ${JSONL_FILE}" +echo "Lines before: ${LINES_BEFORE}" +echo "Method: ${METHOD}" +echo "Budget: ${BUDGET}" +echo "" + +# --- Run supercompact --- +SC_OUTPUT="/tmp/supercompact-output-$$.jsonl" +trap 'rm -f "${SC_OUTPUT}"' EXIT + +START_TIME=$(date +%s%N) + +cd "${SUPERCOMPACT_DIR}" +SC_STDOUT=$(uv run python compact.py compact "${JSONL_FILE}" \ + --method "${METHOD}" \ + --budget "${BUDGET}" \ + --output "${SC_OUTPUT}" \ + --verbose 2>&1) || { + echo "" + echo "${SC_STDOUT}" + echo "" + echo "ERROR: supercompact failed" + exit 1 +} + +END_TIME=$(date +%s%N) +ELAPSED_MS=$(( (END_TIME - START_TIME) / 1000000 )) + +echo "${SC_STDOUT}" + +if [[ ! -f "${SC_OUTPUT}" ]]; then + # Already within budget — not an error + echo "" + echo "Session is already within budget. No compaction needed." + exit 0 +fi + +# --- Backup and replace --- +BACKUP_FILE="${JSONL_FILE}.pre-supercompact" +cp "${JSONL_FILE}" "${BACKUP_FILE}" +mv "${SC_OUTPUT}" "${JSONL_FILE}" +trap - EXIT # output file moved, no cleanup needed + +LINES_AFTER=$(wc -l < "${JSONL_FILE}") + +# --- Report --- +if [[ ${LINES_BEFORE} -gt 0 ]]; then + REDUCTION=$(( (LINES_BEFORE - LINES_AFTER) * 100 / LINES_BEFORE )) +else + REDUCTION=0 +fi + +echo "" +echo "=== Compaction Complete ===" +echo "Lines before: ${LINES_BEFORE}" +echo "Lines after: ${LINES_AFTER}" +echo "Reduction: ${REDUCTION}%" +echo "Time: ${ELAPSED_MS}ms" +echo "Backup: ${BACKUP_FILE}" diff --git a/plugins/claude-code/uninstall.sh b/plugins/claude-code/uninstall.sh index e065ea2..fde814f 100755 --- a/plugins/claude-code/uninstall.sh +++ b/plugins/claude-code/uninstall.sh @@ -131,6 +131,18 @@ if [[ "$DO_REMOVE" == true ]]; then ok "No plugin files found at ${INSTALL_DIR}" fi + # Remove pluginDirs entry from settings.json + SETTINGS_FILE="${HOME}/.claude/settings.json" + PLUGIN_PATH="${INSTALL_DIR}/plugin" + if [[ -f "${SETTINGS_FILE}" ]] && command -v jq &>/dev/null; then + if jq -e --arg p "${PLUGIN_PATH}" '.pluginDirs | index($p)' "${SETTINGS_FILE}" >/dev/null 2>&1; then + info "Removing plugin from settings.json..." + jq --arg p "${PLUGIN_PATH}" '.pluginDirs = [.pluginDirs[] | select(. != $p)]' "${SETTINGS_FILE}" > "${SETTINGS_FILE}.tmp" \ + && mv "${SETTINGS_FILE}.tmp" "${SETTINGS_FILE}" + ok "Plugin removed from settings.json" + fi + fi + # Remove log directory LOG_DIR="${HOME}/.cache/supercompact" if [[ -d "$LOG_DIR" ]]; then diff --git a/tests/test_eval_report.py b/tests/test_eval_report.py new file mode 100644 index 0000000..c3c9091 --- /dev/null +++ b/tests/test_eval_report.py @@ -0,0 +1,200 @@ +"""Tests for lib/eval/report.py — export_json and export_trace.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from lib.eval.report import export_json, export_trace +from lib.eval.aggregate import AggregateResult, DimensionScore + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_result(**overrides) -> AggregateResult: + defaults = dict( + method="dedup", + budget=80_000, + model_key="cheap", + model_label="claude-haiku", + dimensions=[], + composite=0.75, + ndcg=0.82, + speed_s=1.5, + kept_tokens=20_000, + total_tokens=100_000, + ) + defaults.update(overrides) + return AggregateResult(**defaults) + + +def _make_dim(**overrides) -> DimensionScore: + defaults = dict( + dimension="progress", + weight=1.0, + mean_score=0.8, + probe_count=5, + raw_scores=[2, 3, 2, 3, 3], + ) + defaults.update(overrides) + return DimensionScore(**defaults) + + +# --------------------------------------------------------------------------- +# export_json +# --------------------------------------------------------------------------- + +class TestExportJson: + def test_creates_file(self, tmp_path): + path = tmp_path / "results.json" + export_json([], path) + assert path.exists() + + def test_empty_results_writes_empty_array(self, tmp_path): + path = tmp_path / "results.json" + export_json([], path) + data = json.loads(path.read_text()) + assert data == [] + + def test_single_result_structure(self, tmp_path): + result = _make_result() + path = tmp_path / "results.json" + export_json([result], path) + data = json.loads(path.read_text()) + assert len(data) == 1 + entry = data[0] + assert entry["method"] == "dedup" + assert entry["budget"] == 80_000 + assert entry["model_key"] == "cheap" + assert entry["composite"] == pytest.approx(0.75) + assert entry["ndcg"] == pytest.approx(0.82) + + def test_keeps_speed_and_token_counts(self, tmp_path): + result = _make_result(speed_s=2.5, kept_tokens=15_000, total_tokens=80_000) + path = tmp_path / "results.json" + export_json([result], path) + data = json.loads(path.read_text()) + assert data[0]["speed_s"] == pytest.approx(2.5) + assert data[0]["kept_tokens"] == 15_000 + assert data[0]["total_tokens"] == 80_000 + + def test_dimension_scores_included(self, tmp_path): + dim = _make_dim(dimension="error_solution", mean_score=0.9, probe_count=3) + result = _make_result(dimensions=[dim]) + path = tmp_path / "results.json" + export_json([result], path) + data = json.loads(path.read_text()) + dims = data[0]["dimensions"] + assert "error_solution" in dims + assert dims["error_solution"]["score"] == pytest.approx(0.9) + assert dims["error_solution"]["probe_count"] == 3 + + def test_multiple_results(self, tmp_path): + results = [_make_result(method="dedup"), _make_result(method="eitf")] + path = tmp_path / "results.json" + export_json(results, path) + data = json.loads(path.read_text()) + assert len(data) == 2 + methods = {e["method"] for e in data} + assert methods == {"dedup", "eitf"} + + def test_output_is_valid_json(self, tmp_path): + path = tmp_path / "results.json" + export_json([_make_result()], path) + # Should parse without exception + json.loads(path.read_text()) + + +# --------------------------------------------------------------------------- +# export_trace +# --------------------------------------------------------------------------- + +class TestExportTrace: + def _make_probe_set(self, probes=None): + ps = MagicMock() + ps.probes = probes or [] + return ps + + def _make_answer(self, probe_id="p1", score=2, **overrides): + a = MagicMock() + a.probe_id = probe_id + a.score = score + a.model_key = overrides.get("model_key", "cheap") + a.model_label = overrides.get("model_label", "haiku") + a.answer = overrides.get("answer", "some answer") + a.judge_reasoning = overrides.get("judge_reasoning", "looks good") + return a + + def _make_probe(self, id="p1", dimension="progress", tier="factual", + difficulty="medium", question="Q?", gold_answer="A", + evidence_turns=None): + p = MagicMock() + p.id = id + p.dimension = dimension + p.tier = tier + p.difficulty = difficulty + p.question = question + p.gold_answer = gold_answer + p.evidence_turns = evidence_turns or [] + return p + + def test_creates_trace_file(self, tmp_path): + probe_set = self._make_probe_set() + result_path = export_trace("dedup", 80_000, probe_set, [], tmp_path) + assert result_path.exists() + + def test_trace_file_in_trace_dir(self, tmp_path): + probe_set = self._make_probe_set() + result_path = export_trace("eitf", 40_000, probe_set, [], tmp_path) + assert result_path.parent == tmp_path + + def test_trace_filename_contains_method_and_budget(self, tmp_path): + probe_set = self._make_probe_set() + result_path = export_trace("setcover", 60_000, probe_set, [], tmp_path) + assert "setcover" in result_path.name + assert "60000" in result_path.name + + def test_trace_has_method_and_budget(self, tmp_path): + probe_set = self._make_probe_set() + result_path = export_trace("dedup", 80_000, probe_set, [], tmp_path) + data = json.loads(result_path.read_text()) + assert data["method"] == "dedup" + assert data["budget"] == 80_000 + + def test_empty_answers_produces_empty_entries(self, tmp_path): + probe_set = self._make_probe_set() + result_path = export_trace("dedup", 80_000, probe_set, [], tmp_path) + data = json.loads(result_path.read_text()) + assert data["entries"] == [] + + def test_answer_with_matching_probe_included(self, tmp_path): + probe = self._make_probe(id="p1", question="What happened?", gold_answer="Error") + probe_set = self._make_probe_set(probes=[probe]) + answer = self._make_answer(probe_id="p1", score=3, answer="Error occurred") + result_path = export_trace("dedup", 80_000, probe_set, [answer], tmp_path) + data = json.loads(result_path.read_text()) + assert len(data["entries"]) == 1 + entry = data["entries"][0] + assert entry["probe_id"] == "p1" + assert entry["score"] == 3 + + def test_answer_without_matching_probe_skipped(self, tmp_path): + probe_set = self._make_probe_set(probes=[]) + answer = self._make_answer(probe_id="missing_probe") + result_path = export_trace("dedup", 80_000, probe_set, [answer], tmp_path) + data = json.loads(result_path.read_text()) + assert data["entries"] == [] + + def test_creates_trace_dir_if_missing(self, tmp_path): + nested = tmp_path / "a" / "b" / "traces" + probe_set = self._make_probe_set() + export_trace("dedup", 80_000, probe_set, [], nested) + assert nested.is_dir()