From 5914cfb9cd9e7725bbf430d37c1845a15b4cc28f Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 18 May 2026 11:45:08 +0800 Subject: [PATCH] Initial release: aircontext --- .claude-plugin/marketplace.json | 13 ++ .claude-plugin/plugin.json | 58 +++++ README.md | 94 ++++++++ bin/aircontext | 164 ++++++++++++++ bin/aircontext.cmd | 4 + commands/aircontext-init.md | 12 + commands/aircontext-now.md | 14 ++ commands/aircontext-pause.md | 13 ++ commands/aircontext-status.md | 8 + pyproject.toml | 11 + scripts/cmd_init.py | 30 +++ scripts/cmd_now.py | 73 ++++++ scripts/cmd_pause.py | 38 ++++ scripts/cmd_status.py | 57 +++++ scripts/core/__init__.py | 0 scripts/core/auto_init.py | 149 +++++++++++++ scripts/core/backends/__init__.py | 0 scripts/core/backends/anthropic_native.py | 91 ++++++++ scripts/core/backends/base.py | 19 ++ scripts/core/backends/openai_compat.py | 77 +++++++ scripts/core/compactor.py | 260 ++++++++++++++++++++++ scripts/core/config_loader.py | 97 ++++++++ scripts/core/jsonl_ops.py | 158 +++++++++++++ scripts/core/state.py | 93 ++++++++ scripts/core/token_estimator.py | 53 +++++ scripts/on_pre_compact.py | 42 ++++ scripts/on_session_start.py | 79 +++++++ scripts/on_tool_use.py | 106 +++++++++ scripts/on_user_prompt.py | 68 ++++++ templates/README.md | 26 +++ templates/config.yaml | 44 ++++ templates/rules.md | 30 +++ 32 files changed, 1981 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 bin/aircontext create mode 100644 bin/aircontext.cmd create mode 100644 commands/aircontext-init.md create mode 100644 commands/aircontext-now.md create mode 100644 commands/aircontext-pause.md create mode 100644 commands/aircontext-status.md create mode 100644 pyproject.toml create mode 100644 scripts/cmd_init.py create mode 100644 scripts/cmd_now.py create mode 100644 scripts/cmd_pause.py create mode 100644 scripts/cmd_status.py create mode 100644 scripts/core/__init__.py create mode 100644 scripts/core/auto_init.py create mode 100644 scripts/core/backends/__init__.py create mode 100644 scripts/core/backends/anthropic_native.py create mode 100644 scripts/core/backends/base.py create mode 100644 scripts/core/backends/openai_compat.py create mode 100644 scripts/core/compactor.py create mode 100644 scripts/core/config_loader.py create mode 100644 scripts/core/jsonl_ops.py create mode 100644 scripts/core/state.py create mode 100644 scripts/core/token_estimator.py create mode 100644 scripts/on_pre_compact.py create mode 100644 scripts/on_session_start.py create mode 100644 scripts/on_tool_use.py create mode 100644 scripts/on_user_prompt.py create mode 100644 templates/README.md create mode 100644 templates/config.yaml create mode 100644 templates/rules.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..c973b0f --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "name": "aircontext-mkt", + "owner": { + "name": "AirContext" + }, + "plugins": [ + { + "name": "aircontext", + "source": "./", + "description": "Rule-driven, automated context compaction with auto-resume. Replaces Claude Code's auto-compact." + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..ddb5681 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,58 @@ +{ + "name": "aircontext", + "version": "0.1.0", + "description": "User-controlled periodic context compaction with auto-resume. Replaces Claude Code's auto-compact with rule-driven external LLM compression.", + "author": { + "name": "AirContext" + }, + "keywords": [ + "context", + "compaction", + "automation" + ], + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_session_start.py" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_user_prompt.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_tool_use.py" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "auto", + "hooks": [ + { + "type": "command", + "command": "python ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py" + } + ] + } + ] + }, + "repository": "http://git.airlongdian.fun/admin/aircontext.git" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a74a2a --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# AirContext + +A Claude Code plugin that **replaces auto-compact with rule-driven, automated, externally-summarised compaction**, then **auto-resumes** the session so long-running agent loops never break. + +## Why + +Claude Code's built-in auto-compact triggers on context pressure and uses a generic strategy. In a large project, frequent generic compaction degrades subsequent generation quality. AirContext lets you: + +1. Disable Claude's auto-compact (via PreCompact hook). +2. Trigger compaction on **your** schedule (token-ratio threshold + cooldown). +3. Run compaction in **your** LLM (any OpenAI-compatible endpoint — DeepSeek, Ollama, vLLM, LM Studio, etc.) using **your** rules (`AirContext/rules.md`). +4. Apply the result by appending an isolated summary chain (`parentUuid: null`) to the session JSONL, then automatically restart `claude --resume ` and inject a continuation prompt so an in-flight agent loop picks back up unattended. + +## How it works + +``` +$ aircontext # wrapper around `claude` + │ + ▼ + (loops) ← spawns claude → user works as normal + │ + │ PostToolUse hook (every tool call): + │ • estimate active-chain tokens + │ • if > threshold and cooldown elapsed: + │ fork compactor.py (background, non-blocking) + │ + │ compactor.py: + │ • read JSONL → render head as plain text + │ • call LLM with rules.md as system prompt + │ • backup JSONL → snapshots/-.jsonl + │ • append [summary, continuation] with parentUuid=null + │ • set state.compaction_ready = true + │ + ◄──────────┘ + wrapper watcher sees ready → SIGTERM claude → spawn `claude --resume ` + │ + ▼ + new claude loads JSONL: latest leaf is the continuation prompt → auto-replies + the in-flight task continues with ~10× smaller context. +``` + +## Install + +```bash +# from the marketplace once published +/plugin install aircontext@ + +# or directly via settings.json +{ + "extraKnownMarketplaces": { + "aircontext-mkt": { "source": { "source": "github", "repo": "/aircontext-plugin" } } + }, + "enabledPlugins": { "aircontext@aircontext-mkt": true } +} +``` + +Requires Python ≥ 3.10 and `pyyaml`. The wrapper assumes `claude` is on PATH. + +## Use + +```bash +cd +aircontext # instead of `claude` +``` + +First run creates `/AirContext/` with `config.yaml`, `rules.md`, and a per-project README. Edit `config.yaml` (especially `backend.api_key`), then re-run. + +## Per-project files (`AirContext/`) + +| File | Purpose | +| ----------------- | ------------------------------------------------------------- | +| `config.yaml` | Backend, trigger threshold, cooldown, continuation prompt | +| `rules.md` | What to keep / drop — sent to LLM as system prompt | +| `state.json` | Runtime state (managed by plugin, do not edit by hand) | +| `snapshots/` | JSONL backup before each compaction (rotate via `max_snapshots`) | + +## Slash commands + +| Command | Purpose | +| ---------------------- | -------------------------------------------------------- | +| `/aircontext-init` | (Re)install templates into the current project | +| `/aircontext-now` | Force a compaction immediately (bypasses cooldown) | +| `/aircontext-status` | Show config, last compaction, snapshot count | +| `/aircontext-pause` | Toggle (or `on`/`off`) automatic compaction | + +## Caveats + +- **You must launch via `aircontext`, not `claude`, for auto-resume to work.** Without the wrapper, the compactor still prepares the snapshot but you must `claude --resume ` manually for it to take effect. +- The JSONL transcript format is **not a stable public API**. AirContext logs the observed `version` field; if it sees an unfamiliar version, it warns and you may want to enable `safety.dry_run: true` until you've verified compatibility on your side. +- A small fixed cost (system prompt, CLAUDE.md, tool definitions, skills) is reloaded into context every session — this is a Claude Code property, not something AirContext can shrink. + +## License + +MIT diff --git a/bin/aircontext b/bin/aircontext new file mode 100644 index 0000000..6377ab5 --- /dev/null +++ b/bin/aircontext @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +aircontext — wrapper around `claude` providing automatic context compaction loop. + +Usage: + aircontext [args passed through to claude] + +Behaviour: + 1. Verify ./AirContext/ exists and config.yaml is valid (create from templates if missing). + 2. Spawn `claude` as a child process (stdio inherited so UX matches running claude directly). + 3. A watcher thread polls AirContext/state.json; when compaction_ready=true, it: + - terminates the running claude gracefully + - re-spawns `claude --resume ` so the new isolated-summary chain takes effect + 4. Loop exits when the user closes claude without a pending compaction. +""" +from __future__ import annotations +import os +import sys +import signal +import subprocess +import threading +import time +from pathlib import Path + +# Ensure plugin's scripts/ is importable regardless of how the wrapper is invoked. +_PLUGIN_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_PLUGIN_ROOT / "scripts")) + +from core.auto_init import ( # noqa: E402 + auto_configure, + propagate_settings_env, + required_fields_missing, +) +from core.config_loader import ensure_aircontext_dir, validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +WATCH_INTERVAL_SECONDS = 2.0 +TERMINATE_TIMEOUT_SECONDS = 10 + + +def terminate_gracefully(proc: subprocess.Popen) -> None: + """Send a platform-appropriate stop signal, then SIGKILL after timeout.""" + try: + if os.name == "nt": + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + proc.send_signal(signal.SIGTERM) + except (ProcessLookupError, OSError): + return + try: + proc.wait(timeout=TERMINATE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + + +def spawn_claude(resume_id: str | None, passthrough_args: list[str]) -> subprocess.Popen: + cmd = ["claude"] + if resume_id: + cmd += ["--resume", resume_id] + cmd += passthrough_args + env = {**os.environ, "AIRCONTEXT_ACTIVE": "1"} + creationflags = 0 + if os.name == "nt": + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + return subprocess.Popen(cmd, env=env, creationflags=creationflags) + + +def main() -> int: + project_root = Path.cwd() + air = project_root / "AirContext" + + # Make ~/.claude/settings.json `env` block visible in os.environ so the + # validate_config call below resolves ${env:VAR} placeholders the same way + # claude/hooks/compactor will at runtime. + propagate_settings_env() + + created = ensure_aircontext_dir(air, plugin_root=_PLUGIN_ROOT) + config_path = air / "config.yaml" + if created: + cfg = auto_configure(config_path) + window = cfg.get("trigger", {}).get("model_context_window", 200000) + print( + f"[aircontext] AirContext/ initialised at {air}", + file=sys.stderr, + ) + print( + f"[aircontext] Auto-configured: model_context_window={window}, " + f"backend.type={cfg.get('backend', {}).get('type')}", + file=sys.stderr, + ) + + missing = required_fields_missing(config_path) + if missing: + print( + "[aircontext] Please fill these REQUIRED fields in " + f"{config_path}:", + file=sys.stderr, + ) + for m in missing: + print(f"[aircontext] - {m}", file=sys.stderr) + print( + "[aircontext] Then re-run `aircontext` and you'll go straight into claude.", + file=sys.stderr, + ) + return 1 + + err = validate_config(config_path) + if err: + print(f"[aircontext] Invalid config: {err}", file=sys.stderr) + return 1 + + state = StateFile(air / "state.json") + state.reset_for_new_session() + + passthrough = sys.argv[1:] + resume_id = state.last_session_id + + while True: + proc = spawn_claude(resume_id, passthrough) + pending_resume_id: list[str | None] = [None] + stop_watcher = threading.Event() + + def watcher() -> None: + while not stop_watcher.is_set(): + snap = state.read() + if snap.get("compaction_ready"): + rid = snap.get("pending_resume_session_id") + if rid: + pending_resume_id[0] = rid + terminate_gracefully(proc) + return + if stop_watcher.wait(WATCH_INTERVAL_SECONDS): + return + + t = threading.Thread(target=watcher, daemon=True) + t.start() + try: + proc.wait() + except KeyboardInterrupt: + terminate_gracefully(proc) + finally: + stop_watcher.set() + t.join(timeout=3) + + if pending_resume_id[0]: + resume_id = pending_resume_id[0] + state.clear_ready() + print( + f"[aircontext] Compaction applied, resuming session {resume_id}", + file=sys.stderr, + ) + continue + + # User exited without a pending compaction — finish. + return proc.returncode or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/aircontext.cmd b/bin/aircontext.cmd new file mode 100644 index 0000000..fedb53e --- /dev/null +++ b/bin/aircontext.cmd @@ -0,0 +1,4 @@ +@echo off +REM Windows launcher for the AirContext wrapper. +REM Forwards all args to the Python script next to this file. +python "%~dp0aircontext" %* diff --git a/commands/aircontext-init.md b/commands/aircontext-init.md new file mode 100644 index 0000000..ed8beb4 --- /dev/null +++ b/commands/aircontext-init.md @@ -0,0 +1,12 @@ +--- +description: Install AirContext templates into the current project +allowed-tools: Bash +--- + +Run the initialiser to create or repair `AirContext/` in the current project root. +After it completes, tell the user to edit `AirContext/config.yaml` (specifically +`backend.api_key` and `backend.endpoint`) before relying on automatic compaction. + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_init.py" +``` diff --git a/commands/aircontext-now.md b/commands/aircontext-now.md new file mode 100644 index 0000000..7d1dd52 --- /dev/null +++ b/commands/aircontext-now.md @@ -0,0 +1,14 @@ +--- +description: Force an immediate compaction (ignores cooldown and threshold) +allowed-tools: Bash +--- + +Spawn the compactor in the foreground for the current session and report the +outcome. After it succeeds, the wrapper will detect `compaction_ready` within +a few seconds and restart claude with `--resume`. If you launched claude +directly without the `aircontext` wrapper, you must exit and run +`claude --resume ` yourself for the new chain to take effect. + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_now.py" --session-id "${CLAUDE_SESSION_ID}" +``` diff --git a/commands/aircontext-pause.md b/commands/aircontext-pause.md new file mode 100644 index 0000000..30ef2be --- /dev/null +++ b/commands/aircontext-pause.md @@ -0,0 +1,13 @@ +--- +description: Pause or resume AirContext automatic compaction (toggle) +argument-hint: "[on|off]" +allowed-tools: Bash +--- + +Toggle (or explicitly set) the automatic-compaction switch. While paused, the +PostToolUse ticker still runs but skips firing the compactor. Manual +`/aircontext-now` is unaffected. + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_pause.py" $ARGUMENTS +``` diff --git a/commands/aircontext-status.md b/commands/aircontext-status.md new file mode 100644 index 0000000..12df1ec --- /dev/null +++ b/commands/aircontext-status.md @@ -0,0 +1,8 @@ +--- +description: Show current AirContext state, last compaction, and pending actions +allowed-tools: Bash +--- + +```! +python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_status.py" +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8f64bbc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "aircontext-plugin" +version = "0.1.0" +description = "Claude Code plugin for rule-driven, automated context compaction with auto-resume." +requires-python = ">=3.10" +dependencies = [ + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] diff --git a/scripts/cmd_init.py b/scripts/cmd_init.py new file mode 100644 index 0000000..8bf8a76 --- /dev/null +++ b/scripts/cmd_init.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-init.""" +from __future__ import annotations +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import ensure_aircontext_dir, validate_config # noqa: E402 + + +def main() -> int: + plugin_root = Path(os.environ.get("CLAUDE_PLUGIN_ROOT", _HERE.parent)) + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + created = ensure_aircontext_dir(air, plugin_root=plugin_root) + print(f"AirContext directory: {air}") + print("Templates installed." if created else "Templates already present (no overwrite).") + err = validate_config(air / "config.yaml") + if err: + print(f"Config status: needs attention — {err}") + else: + print("Config status: OK (compaction will activate on next session).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/cmd_now.py b/scripts/cmd_now.py new file mode 100644 index 0000000..817c9d4 --- /dev/null +++ b/scripts/cmd_now.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-now — force a compaction synchronously.""" +from __future__ import annotations +import argparse +import json +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.compactor import run as run_compactor # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def _find_transcript(project: Path, session_id: str) -> Path | None: + """Locate the JSONL transcript for the given session. + + Claude Code stores transcripts under + ~/.claude/projects//sessions/.jsonl + The cwd encoding replaces path separators with `-`. We search defensively. + """ + home = Path.home() / ".claude" / "projects" + if not home.exists(): + return None + encoded = str(project).replace(os.sep, "-").replace(":", "") + # Try direct match first + candidate = home / encoded / "sessions" / f"{session_id}.jsonl" + if candidate.exists(): + return candidate + # Fall back: scan for the session id under any project directory + for p in home.glob(f"*/sessions/{session_id}.jsonl"): + return p + return None + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--session-id", required=True) + args = p.parse_args() + + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + if not (air / "config.yaml").exists(): + print("AirContext not initialised. Run /aircontext-init first.") + return 1 + + transcript = _find_transcript(project, args.session_id) + if not transcript: + print(f"Could not locate transcript for session {args.session_id}.") + return 1 + + state = StateFile(air / "state.json") + # Force one-shot regardless of cooldown + state.update(last_compaction_unix=0) + print(f"Compacting transcript: {transcript}") + rc = run_compactor(project, transcript, args.session_id) + snap = state.read() + if rc == 0 and snap.get("compaction_ready"): + print("Compaction prepared. The wrapper will restart claude shortly.") + print("If you launched claude directly (without `aircontext`), exit and " + f"run: claude --resume {args.session_id}") + elif rc == 0: + print("Compactor returned success but no compaction was applied " + "(see compactor.log for reason — likely chain too short or paused).") + else: + print(f"Compactor failed with exit code {rc}. See AirContext/snapshots/compactor.log") + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/cmd_pause.py b/scripts/cmd_pause.py new file mode 100644 index 0000000..d603e21 --- /dev/null +++ b/scripts/cmd_pause.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-pause [on|off].""" +from __future__ import annotations +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.state import StateFile # noqa: E402 + + +def main() -> int: + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + if not air.exists(): + print("AirContext not initialised. Run /aircontext-init first.") + return 1 + + state = StateFile(air / "state.json") + arg = (sys.argv[1].lower() if len(sys.argv) > 1 else "").strip() + + cur = bool(state.read().get("paused")) + if arg in ("on", "pause", "true", "1"): + new = True + elif arg in ("off", "resume", "false", "0"): + new = False + else: + new = not cur # toggle + + state.update(paused=new) + print(f"AirContext automatic compaction: {'PAUSED' if new else 'ACTIVE'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/cmd_status.py b/scripts/cmd_status.py new file mode 100644 index 0000000..db88bde --- /dev/null +++ b/scripts/cmd_status.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Backend for /aircontext-status.""" +from __future__ import annotations +import json +import os +import sys +import time +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import load_config, validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def main() -> int: + project = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())) + air = project / "AirContext" + cfg_path = air / "config.yaml" + print(f"Project root: {project}") + print(f"AirContext dir: {air} {'(present)' if air.exists() else '(MISSING)'}") + if not cfg_path.exists(): + print("Status: not initialised. Run /aircontext-init.") + return 0 + err = validate_config(cfg_path) + if err: + print(f"Config: invalid — {err}") + else: + cfg = load_config(cfg_path) + b = cfg.get("backend", {}) + t = cfg.get("trigger", {}) + print(f"Backend: {b.get('endpoint')} (model={b.get('model')})") + print( + f"Trigger: strategy={t.get('strategy')} " + f"threshold={t.get('threshold')} window={t.get('model_context_window')}" + ) + + state = StateFile(air / "state.json").read() + print(f"Paused: {state.get('paused')}") + print(f"Compacting now: {state.get('compaction_in_progress')}") + print(f"Ready to apply: {state.get('compaction_ready')}") + last = state.get("last_compaction_unix") or 0 + if last: + ago = int(time.time() - last) + print(f"Last compaction: {ago}s ago") + else: + print("Last compaction: never") + snapshots = air / "snapshots" + if snapshots.exists(): + files = [p for p in snapshots.iterdir() if p.suffix == ".jsonl"] + print(f"Snapshots: {len(files)} stored under {snapshots}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/core/__init__.py b/scripts/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/core/auto_init.py b/scripts/core/auto_init.py new file mode 100644 index 0000000..4fd54b7 --- /dev/null +++ b/scripts/core/auto_init.py @@ -0,0 +1,149 @@ +"""Auto-fill non-private fields when AirContext/ is freshly created. + +Privacy boundary: + - backend.api_key and backend.endpoint are left for the USER to fill. + - Everything else (model_context_window, threshold, cooldown, compaction + rules, etc.) is auto-configured here using the host machine's environment. + +Inputs we read: + - ~/.claude/settings.json (specifically `model` and `env` sections) + - os.environ (overrides settings.json env when both set) + +Outputs: + - Mutated AirContext/config.yaml on disk + - Side-effect: settings.json's `env` keys are copied into os.environ so the + wrapper's own validate_config call resolves ${env:VAR} placeholders + consistently with what claude (and thus its hooks/compactor) will see. +""" +from __future__ import annotations +import json +import os +from pathlib import Path +from typing import Any + +try: + import yaml # type: ignore +except ImportError: # pragma: no cover — pyyaml is a hard dep declared in pyproject + yaml = None + + +# Approximate context windows for known Claude model IDs / aliases. +_MODEL_WINDOWS = { + "opus[1m]": 1_000_000, + "claude-opus-4-7[1m]": 1_000_000, + "claude-opus-4-7": 200_000, + "claude-sonnet-4-6": 200_000, + "claude-sonnet-4-5": 200_000, + "claude-haiku-4-5": 200_000, + "claude-haiku-4-5-20251001": 200_000, +} + + +def _read_user_settings() -> dict: + p = Path.home() / ".claude" / "settings.json" + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def propagate_settings_env() -> dict[str, str]: + """Copy ~/.claude/settings.json `env` block into os.environ if not already set. + + Claude Code injects these into its own subprocess environment, but the + wrapper itself is launched from the user's shell and doesn't inherit them. + Without this propagation, the wrapper's validate_config sees empty + ${env:ANTHROPIC_AUTH_TOKEN} and bails out, even though the env var WILL be + set when the compactor actually runs (since it's a child of claude). + + Returns the env dict that was applied. + """ + settings = _read_user_settings() + env_block = settings.get("env") or {} + if not isinstance(env_block, dict): + return {} + for k, v in env_block.items(): + if isinstance(v, str): + os.environ.setdefault(k, v) + return env_block + + +def detect_claude_context_window() -> int | None: + """Infer the active claude model's context window from settings.json.""" + settings = _read_user_settings() + model = settings.get("model") + if not isinstance(model, str): + return None + if model in _MODEL_WINDOWS: + return _MODEL_WINDOWS[model] + if "[1m]" in model.lower(): + return 1_000_000 + return 200_000 # safe default for any modern Claude model + + +def auto_configure(config_path: Path) -> dict[str, Any]: + """Fill non-private fields in the freshly-created config. + + Returns the merged config dict (also written back to disk). + Raises RuntimeError if PyYAML is missing. + """ + if yaml is None: + raise RuntimeError("PyYAML required (pip install pyyaml)") + + propagate_settings_env() + + cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + cfg.setdefault("backend", {}) + cfg.setdefault("trigger", {}) + cfg.setdefault("compaction", {}) + cfg.setdefault("safety", {}) + + window = detect_claude_context_window() + if window: + cfg["trigger"]["model_context_window"] = window + + # If user has ANTHROPIC_AUTH_TOKEN in settings.json/env, prefer + # anthropic_native as the default backend type (most likely match). + # api_key/endpoint themselves stay user-fillable. + has_anthropic = bool( + os.environ.get("ANTHROPIC_AUTH_TOKEN") + or os.environ.get("ANTHROPIC_API_KEY") + ) + has_openai = bool(os.environ.get("OPENAI_API_KEY")) + if has_anthropic and not has_openai: + cfg["backend"].setdefault("type", "anthropic_native") + elif has_openai and not has_anthropic: + cfg["backend"].setdefault("type", "openai_compat") + # Otherwise leave whatever the template specified. + + config_path.write_text( + yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + return cfg + + +def required_fields_missing(config_path: Path) -> list[str]: + """Return the names of REQUIRED-USER-FILL fields that are still empty. + + These are the privacy-boundary fields: endpoint and api_key. Any + auto-configurable field is NOT in this list. + """ + if yaml is None: + return ["[pyyaml not installed]"] + cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + backend = cfg.get("backend") or {} + missing: list[str] = [] + endpoint = (backend.get("endpoint") or "").strip() + if not endpoint: + missing.append("backend.endpoint") + + api_key_raw = backend.get("api_key") or "" + # api_key may be a ${env:VAR} placeholder. Resolve via current env. + from .config_loader import resolve_env_placeholders + resolved = resolve_env_placeholders(api_key_raw) + if isinstance(resolved, str) and not resolved.strip(): + missing.append("backend.api_key") + return missing diff --git a/scripts/core/backends/__init__.py b/scripts/core/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/core/backends/anthropic_native.py b/scripts/core/backends/anthropic_native.py new file mode 100644 index 0000000..5446b1c --- /dev/null +++ b/scripts/core/backends/anthropic_native.py @@ -0,0 +1,91 @@ +"""Anthropic-native /v1/messages backend. + +For endpoints that speak the Anthropic Messages API (api.anthropic.com or any +proxy compatible with it). Uses x-api-key + anthropic-version headers. +""" +from __future__ import annotations +import json +import urllib.error +import urllib.request +from dataclasses import dataclass + + +DEFAULT_ANTHROPIC_VERSION = "2023-06-01" + + +@dataclass +class AnthropicNativeBackend: + cfg: dict + + @property + def endpoint(self) -> str: + return self.cfg["endpoint"].rstrip("/") + + @property + def model(self) -> str: + return self.cfg["model"] + + @property + def api_key(self) -> str: + return self.cfg.get("api_key") or "missing-key" + + @property + def max_output_tokens(self) -> int: + return int(self.cfg.get("max_output_tokens", 4000)) + + @property + def timeout(self) -> int: + return int(self.cfg.get("timeout_seconds", 60)) + + @property + def anthropic_version(self) -> str: + return self.cfg.get("anthropic_version", DEFAULT_ANTHROPIC_VERSION) + + def summarise(self, system_prompt: str, conversation_text: str) -> str: + url = f"{self.endpoint}/v1/messages" + body = { + "model": self.model, + "max_tokens": self.max_output_tokens, + "temperature": 0.2, + "system": system_prompt, + "messages": [ + {"role": "user", "content": conversation_text}, + ], + } + data = json.dumps(body).encode("utf-8") + headers = { + "Content-Type": "application/json", + "x-api-key": self.api_key, + "anthropic-version": self.anthropic_version, + } + # Some proxies (e.g. wolfai.top) accept Bearer tokens too — send both + # so we work whether the upstream wants x-api-key or Authorization. + if self.api_key.startswith("sk-"): + headers["Authorization"] = f"Bearer {self.api_key}" + + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"LLM connection failed: {e.reason}") from e + + # Anthropic format: {"content": [{"type":"text","text":"..."}], ...} + content = payload.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text", "") + if text.strip(): + return text.strip() + # Some proxies pass through OpenAI shape — fall back gracefully. + choices = payload.get("choices") or [] + if choices: + msg = choices[0].get("message") or {} + content = msg.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + raise RuntimeError(f"LLM returned unexpected payload shape: {payload}") diff --git a/scripts/core/backends/base.py b/scripts/core/backends/base.py new file mode 100644 index 0000000..125d1ab --- /dev/null +++ b/scripts/core/backends/base.py @@ -0,0 +1,19 @@ +"""Backend interface used by compactor.py.""" +from __future__ import annotations +from typing import Protocol + + +class CompressionBackend(Protocol): + def summarise(self, system_prompt: str, conversation_text: str) -> str: ... + + +def build_backend(cfg: dict) -> CompressionBackend: + backend_cfg = cfg.get("backend") or {} + btype = backend_cfg.get("type", "openai_compat") + if btype == "openai_compat": + from .openai_compat import OpenAICompatibleBackend + return OpenAICompatibleBackend(backend_cfg) + if btype == "anthropic_native": + from .anthropic_native import AnthropicNativeBackend + return AnthropicNativeBackend(backend_cfg) + raise ValueError(f"Unsupported backend.type: {btype!r}") diff --git a/scripts/core/backends/openai_compat.py b/scripts/core/backends/openai_compat.py new file mode 100644 index 0000000..60d10ce --- /dev/null +++ b/scripts/core/backends/openai_compat.py @@ -0,0 +1,77 @@ +"""OpenAI-compatible chat-completions backend. + +Covers OpenAI / Azure OpenAI / DeepSeek / Moonshot / Qwen-cloud / Ollama / +vLLM / LM Studio — anything that exposes `POST {endpoint}/chat/completions`. + +We avoid the openai SDK to keep the plugin's dependency surface to stdlib + +pyyaml. Uses urllib so even environments without `requests` work. +""" +from __future__ import annotations +import json +import urllib.error +import urllib.request +from dataclasses import dataclass + + +@dataclass +class OpenAICompatibleBackend: + cfg: dict + + @property + def endpoint(self) -> str: + return self.cfg["endpoint"].rstrip("/") + + @property + def model(self) -> str: + return self.cfg["model"] + + @property + def api_key(self) -> str: + return self.cfg.get("api_key") or "missing-key" + + @property + def max_output_tokens(self) -> int: + return int(self.cfg.get("max_output_tokens", 4000)) + + @property + def timeout(self) -> int: + return int(self.cfg.get("timeout_seconds", 60)) + + def summarise(self, system_prompt: str, conversation_text: str) -> str: + url = f"{self.endpoint}/chat/completions" + body = { + "model": self.model, + "max_tokens": self.max_output_tokens, + "temperature": 0.2, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": conversation_text}, + ], + } + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"LLM connection failed: {e.reason}") from e + + choices = payload.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM returned no choices: {payload}") + msg = choices[0].get("message") or {} + content = msg.get("content") + if not isinstance(content, str) or not content.strip(): + raise RuntimeError(f"LLM returned empty content: {payload}") + return content.strip() diff --git a/scripts/core/compactor.py b/scripts/core/compactor.py new file mode 100644 index 0000000..b3422ac --- /dev/null +++ b/scripts/core/compactor.py @@ -0,0 +1,260 @@ +"""Compactor — runs out-of-band, mutates JSONL, signals wrapper to resume. + +Invocation (by on_tool_use.py or /aircontext-now): + python compactor.py --project --transcript --session-id + +Lifecycle: + 1. Acquire single-instance lock (compactor.lock). + 2. Mark state.compaction_in_progress=true (already set by hook, idempotent). + 3. Load active chain. If too short, abort. + 4. Backup JSONL. + 5. Slice tail (preserved) vs head (to compress). + 6. Build conversation text + system prompt (rules.md). + 7. Call LLM backend. + 8. Append [summary, continuation] messages with parentUuid=null chain head. + 9. Write state.compaction_ready=true so wrapper restarts claude. +""" +from __future__ import annotations +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +_HERE = Path(__file__).resolve().parent.parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from core.backends.base import build_backend # noqa: E402 +from core.config_loader import load_config # noqa: E402 +from core.jsonl_ops import ( # noqa: E402 + append_isolated_summary, + backup_jsonl, + find_active_chain, + load_messages, + rotate_snapshots, +) +from core.state import StateFile # noqa: E402 + + +MIN_CHAIN_TO_COMPACT = 6 + + +def _serialise_message_for_llm(msg: dict[str, Any]) -> str | None: + """Reduce a JSONL message to plain text the LLM can read. + + Returns None for messages that should be skipped entirely + (file-history-snapshot, queue-operation, etc.). + """ + t = msg.get("type") + inner = msg.get("message") or {} + if t == "user": + content = inner.get("content") + if isinstance(content, str): + return f"USER: {content}" + if isinstance(content, list): + parts = [p.get("text", "") if isinstance(p, dict) else str(p) for p in content] + return "USER: " + " ".join(p for p in parts if p) + if t == "assistant": + content = inner.get("content") + if isinstance(content, str): + return f"ASSISTANT: {content}" + if isinstance(content, list): + parts: list[str] = [] + for p in content: + if not isinstance(p, dict): + continue + if p.get("type") == "text": + parts.append(p.get("text", "")) + elif p.get("type") == "tool_use": + parts.append( + f"[tool_use {p.get('name')}({json.dumps(p.get('input', {}), ensure_ascii=False)[:300]})]" + ) + elif p.get("type") == "thinking": + pass # drop + return "ASSISTANT: " + " ".join(s for s in parts if s) + if t == "tool_result": + # `message.content` for tool_result is the result text + content = inner.get("content") if isinstance(inner, dict) else msg.get("content") + if isinstance(content, list): + content = " ".join( + (p.get("text", "") if isinstance(p, dict) else str(p)) for p in content + ) + if not isinstance(content, str): + return None + return f"TOOL_RESULT: {content}" + if t in ("system",): + content = inner.get("content") + if isinstance(content, str): + return f"SYSTEM: {content}" + return None + + +def _truncate_long_tool_results(rendered: list[str], max_lines: int) -> list[str]: + out: list[str] = [] + for r in rendered: + if not r.startswith("TOOL_RESULT: "): + out.append(r) + continue + body = r[len("TOOL_RESULT: "):] + lines = body.splitlines() + if len(lines) <= max_lines: + out.append(r) + continue + head = "\n".join(lines[:50]) + tail = "\n".join(lines[-50:]) + out.append( + "TOOL_RESULT: " + + head + + f"\n... [{len(lines) - 100} lines omitted by AirContext pre-truncation] ...\n" + + tail + ) + return out + + +def run(project: Path, transcript: Path, session_id: str) -> int: + air = project / "AirContext" + cfg_path = air / "config.yaml" + state = StateFile(air / "state.json") + + try: + cfg = load_config(cfg_path) + except Exception as e: + print(f"[compactor] config load failed: {e}", file=sys.stderr) + state.update(compaction_in_progress=False) + return 2 + + if state.read().get("paused"): + state.update(compaction_in_progress=False) + return 0 + + # Single-instance lock (best-effort; sufficient for single-user dev environment). + lock = air / "compactor.lock" + try: + fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(os.getpid()).encode()) + os.close(fd) + except FileExistsError: + print("[compactor] another compactor is already running; exiting", file=sys.stderr) + return 0 + + try: + return _run_locked(project, transcript, session_id, cfg, state, air) + finally: + try: + os.unlink(lock) + except OSError: + pass + + +def _run_locked( + project: Path, + transcript: Path, + session_id: str, + cfg: dict, + state: StateFile, + air: Path, +) -> int: + state.update(compaction_in_progress=True, last_session_id=session_id) + msgs = load_messages(transcript) + chain = find_active_chain(msgs) + if len(chain) < MIN_CHAIN_TO_COMPACT: + print( + f"[compactor] chain too short ({len(chain)}); skipping", + file=sys.stderr, + ) + state.update(compaction_in_progress=False) + return 0 + + comp_cfg = cfg.get("compaction") or {} + safety = cfg.get("safety") or {} + preserve_tail = int(comp_cfg.get("preserve_tail_messages", 10)) + drop_lines = int(comp_cfg.get("drop_tool_results_over_lines", 1000)) + rules_file = comp_cfg.get("rules_file", "rules.md") + continuation_prompt = comp_cfg.get("continuation_prompt", "") or "" + + rules_path = air / rules_file + rules_text = rules_path.read_text(encoding="utf-8") if rules_path.exists() else "" + + head = chain[:-preserve_tail] if preserve_tail > 0 else chain + tail = chain[-preserve_tail:] if preserve_tail > 0 else [] + + rendered = [r for r in (_serialise_message_for_llm(m) for m in head) if r] + rendered = _truncate_long_tool_results(rendered, drop_lines) + conversation_text = "\n\n".join(rendered) + if not conversation_text.strip(): + print("[compactor] nothing to summarise", file=sys.stderr) + state.update(compaction_in_progress=False) + return 0 + + system_prompt = ( + "You are a context-compression engine for a Claude Code session. " + "Apply the user-supplied compression rules below to the conversation " + "transcript that follows. Output ONLY the compressed summary text. " + "Do not add preamble, headers, or apologies.\n\n" + "=== USER COMPRESSION RULES ===\n" + f"{rules_text}\n" + "=== END RULES ===" + ) + + backend = build_backend(cfg) + print( + f"[compactor] calling {cfg['backend']['endpoint']} model={cfg['backend']['model']} " + f"head_msgs={len(head)} tail_preserved={len(tail)}", + file=sys.stderr, + ) + try: + summary_text = backend.summarise(system_prompt, conversation_text) + except Exception as e: + print(f"[compactor] LLM call failed: {e}", file=sys.stderr) + state.update(compaction_in_progress=False) + return 3 + + if safety.get("dry_run"): + print( + f"[compactor] dry_run=true; summary length={len(summary_text)}; " + "JSONL not modified", + file=sys.stderr, + ) + state.update(compaction_in_progress=False, last_compaction_unix=int(time.time())) + return 0 + + if safety.get("backup", True): + backup_jsonl(transcript, air / "snapshots", session_id) + + template = chain[-1] # use the latest message's metadata as template + summary_uuid, cont_uuid = append_isolated_summary( + transcript, + summary_text=summary_text, + continuation_text=continuation_prompt or None, + template_message=template, + ) + print( + f"[compactor] appended summary={summary_uuid} continuation={cont_uuid}", + file=sys.stderr, + ) + + rotate_snapshots(air / "snapshots", int(safety.get("max_snapshots", 50))) + + state.update( + compaction_in_progress=False, + compaction_ready=True, + pending_resume_session_id=session_id, + last_compaction_unix=int(time.time()), + ) + return 0 + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--project", required=True) + p.add_argument("--transcript", required=True) + p.add_argument("--session-id", required=True) + args = p.parse_args() + return run(Path(args.project), Path(args.transcript), args.session_id) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/core/config_loader.py b/scripts/core/config_loader.py new file mode 100644 index 0000000..1e5fc27 --- /dev/null +++ b/scripts/core/config_loader.py @@ -0,0 +1,97 @@ +"""AirContext/ self-check + template installation + config validation.""" +from __future__ import annotations +import os +import re +import shutil +from pathlib import Path +from typing import Any + +try: + import yaml # type: ignore +except ImportError: + yaml = None # validate_config will surface a clear error + + +REQUIRED_FILES = ("config.yaml", "rules.md") + + +def ensure_aircontext_dir(air: Path, plugin_root: Path) -> bool: + """Create AirContext/ from templates if missing. + + Returns True if templates were just installed (caller should ask user to + edit config.yaml). Returns False if the dir already existed. + """ + templates = plugin_root / "templates" + if air.exists() and (air / "config.yaml").exists(): + # Make sure subdirs exist even on partial installs. + (air / "snapshots").mkdir(exist_ok=True) + return False + + air.mkdir(parents=True, exist_ok=True) + (air / "snapshots").mkdir(exist_ok=True) + for name in REQUIRED_FILES: + src = templates / name + dst = air / name + if not dst.exists() and src.exists(): + shutil.copyfile(src, dst) + # README is optional but nice + readme_src = templates / "README.md" + readme_dst = air / "README.md" + if not readme_dst.exists() and readme_src.exists(): + shutil.copyfile(readme_src, readme_dst) + return True + + +_ENV_RE = re.compile(r"\$\{env:([A-Z_][A-Z0-9_]*)\}") + + +def resolve_env_placeholders(value: Any) -> Any: + """Replace ${env:VAR} placeholders inside string values, recursively.""" + if isinstance(value, str): + def _sub(m: re.Match[str]) -> str: + return os.environ.get(m.group(1), "") + return _ENV_RE.sub(_sub, value) + if isinstance(value, dict): + return {k: resolve_env_placeholders(v) for k, v in value.items()} + if isinstance(value, list): + return [resolve_env_placeholders(v) for v in value] + return value + + +def load_config(path: Path) -> dict[str, Any]: + if yaml is None: + raise RuntimeError("PyYAML not installed. `pip install pyyaml` and retry.") + with path.open("r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + return resolve_env_placeholders(raw) + + +def validate_config(path: Path) -> str | None: + """Return None on success, or a human-readable error string.""" + if not path.exists(): + return f"{path} not found" + try: + cfg = load_config(path) + except Exception as e: # pragma: no cover + return f"failed to parse {path.name}: {e}" + + backend = cfg.get("backend") or {} + if not backend.get("endpoint"): + return "backend.endpoint is required" + if not backend.get("model"): + return "backend.model is required" + api_key = backend.get("api_key", "") + if not api_key: + return "backend.api_key is empty (set AIRCONTEXT_API_KEY env var or fill config.yaml)" + + trig = cfg.get("trigger") or {} + threshold = trig.get("threshold") + if not isinstance(threshold, (int, float)) or not 0 < float(threshold) < 1: + return "trigger.threshold must be a number between 0 and 1" + + comp = cfg.get("compaction") or {} + rules_file = comp.get("rules_file", "rules.md") + if not (path.parent / rules_file).exists(): + return f"rules file not found: {rules_file}" + + return None diff --git a/scripts/core/jsonl_ops.py b/scripts/core/jsonl_ops.py new file mode 100644 index 0000000..b63890f --- /dev/null +++ b/scripts/core/jsonl_ops.py @@ -0,0 +1,158 @@ +"""JSONL transcript parsing + isolated-summary append. + +The Claude Code transcript is a JSON-Lines file where each line is a message. +Messages form a DAG via `parentUuid`; the "active chain" is the path from the +latest leaf back to the first message with parentUuid==null. + +We append a NEW chain by writing two `user` messages at the tail of the file: + 1. summary (parentUuid: null) — becomes a fresh chain root + 2. continuation (parentUuid: summary.uuid) — becomes the latest leaf + +On `claude --resume ` the leaf-selection algorithm picks the continuation +message (newest leaf) and walks back to the summary, so the loaded context is +exactly those two messages plus the system/CLAUDE.md/etc. fixed cost. +""" +from __future__ import annotations +import datetime as _dt +import json +import shutil +import uuid as _uuid +from pathlib import Path +from typing import Any, Iterator + + +def iter_messages(path: Path) -> Iterator[dict[str, Any]]: + with path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + +def load_messages(path: Path) -> list[dict[str, Any]]: + return list(iter_messages(path)) + + +def find_active_chain(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return messages on the active chain, ordered from root to leaf. + + Mirrors `claude --resume` behaviour: + 1. Build uuid -> message map. + 2. Find leaves: uuids not referenced as a parentUuid by any other message. + 3. Pick leaf with latest timestamp. + 4. Walk parentUuid pointers back until parentUuid is null. + """ + by_uuid = {m["uuid"]: m for m in messages if "uuid" in m} + referenced = {m.get("parentUuid") for m in messages if m.get("parentUuid")} + leaves = [m for m in messages if m.get("uuid") and m["uuid"] not in referenced] + if not leaves: + return [] + + def _ts(m: dict[str, Any]) -> str: + return m.get("timestamp", "") + + leaf = max(leaves, key=_ts) + + chain: list[dict[str, Any]] = [] + cur: dict[str, Any] | None = leaf + seen: set[str] = set() + while cur is not None and cur.get("uuid") not in seen: + chain.append(cur) + seen.add(cur["uuid"]) + parent_uuid = cur.get("parentUuid") + if not parent_uuid: + break + cur = by_uuid.get(parent_uuid) + chain.reverse() + return chain + + +def _meta_from(reference: dict[str, Any]) -> dict[str, Any]: + """Copy non-content metadata fields from a reference message. + + We deliberately only copy metadata fields, never the content/message field, + so callers control what payload the new message carries. + """ + keep = ("sessionId", "cwd", "version", "gitBranch", "userType") + return {k: reference[k] for k in keep if k in reference} + + +def _now_iso() -> str: + return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ) + + +def build_user_message( + *, + parent_uuid: str | None, + content: str, + template: dict[str, Any], +) -> dict[str, Any]: + msg: dict[str, Any] = { + "type": "user", + "uuid": str(_uuid.uuid4()), + "parentUuid": parent_uuid, + "timestamp": _now_iso(), + "isSidechain": False, + **_meta_from(template), + "message": {"role": "user", "content": content}, + } + msg.setdefault("userType", "external") + return msg + + +def backup_jsonl(path: Path, snapshots_dir: Path, session_id: str) -> Path: + snapshots_dir.mkdir(parents=True, exist_ok=True) + ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + dst = snapshots_dir / f"{ts}-{session_id}.jsonl" + shutil.copyfile(path, dst) + return dst + + +def append_isolated_summary( + path: Path, + *, + summary_text: str, + continuation_text: str | None, + template_message: dict[str, Any], +) -> tuple[str, str | None]: + """Append summary (parentUuid=null) and optional continuation. + + Returns (summary_uuid, continuation_uuid_or_None). + """ + summary = build_user_message( + parent_uuid=None, content=summary_text, template=template_message + ) + lines: list[str] = [json.dumps(summary, ensure_ascii=False) + "\n"] + cont_uuid: str | None = None + if continuation_text: + cont = build_user_message( + parent_uuid=summary["uuid"], + content=continuation_text, + template=template_message, + ) + cont_uuid = cont["uuid"] + lines.append(json.dumps(cont, ensure_ascii=False) + "\n") + with path.open("a", encoding="utf-8") as f: + f.writelines(lines) + return summary["uuid"], cont_uuid + + +def rotate_snapshots(snapshots_dir: Path, max_keep: int) -> None: + if not snapshots_dir.exists(): + return + files = sorted( + (p for p in snapshots_dir.iterdir() if p.is_file() and p.suffix == ".jsonl"), + key=lambda p: p.stat().st_mtime, + ) + excess = len(files) - max_keep + for p in files[:max(0, excess)]: + try: + p.unlink() + except OSError: + pass diff --git a/scripts/core/state.py b/scripts/core/state.py new file mode 100644 index 0000000..5855b03 --- /dev/null +++ b/scripts/core/state.py @@ -0,0 +1,93 @@ +"""state.json read/write — shared between wrapper, hooks, and compactor. + +Schema: +{ + "config_missing": bool, # set by SessionStart hook when AirContext/ template was just created + "last_session_id": str | None, # the most recent claude session id we observed + "pending_resume_session_id": str | null,# session id to resume after wrapper terminates claude + "compaction_ready": bool, # compactor sets to true; wrapper consumes + "compaction_in_progress": bool, # compactor sets to true while running, prevents re-entry + "last_compaction_unix": int, # cooldown reference + "last_tool_count_at_compact": int, # for tool_count strategy (reserved) + "paused": bool # /aircontext-pause toggle +} +""" +from __future__ import annotations +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +_DEFAULT: dict[str, Any] = { + "config_missing": False, + "last_session_id": None, + "pending_resume_session_id": None, + "compaction_ready": False, + "compaction_in_progress": False, + "last_compaction_unix": 0, + "last_tool_count_at_compact": 0, + "paused": False, +} + + +class StateFile: + """Best-effort JSON state store. Writes are atomic via tmp-file rename. + + Concurrency: hook scripts and the wrapper read/write concurrently. We accept + last-write-wins semantics for non-critical fields. The compactor uses a + separate lock file (see compactor.py) for the critical 'do not start two + compactions at once' invariant. + """ + + def __init__(self, path: Path) -> None: + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + if not self.path.exists(): + self._write_atomic(_DEFAULT.copy()) + + @property + def last_session_id(self) -> str | None: + return self.read().get("last_session_id") + + def read(self) -> dict[str, Any]: + try: + with self.path.open("r", encoding="utf-8") as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + data = _DEFAULT.copy() + # backfill defaults for forward-compat + for k, v in _DEFAULT.items(): + data.setdefault(k, v) + return data + + def update(self, **kwargs: Any) -> dict[str, Any]: + data = self.read() + data.update(kwargs) + self._write_atomic(data) + return data + + def reset_for_new_session(self) -> None: + self.update( + compaction_ready=False, + compaction_in_progress=False, + pending_resume_session_id=None, + ) + + def clear_ready(self) -> None: + self.update(compaction_ready=False, pending_resume_session_id=None) + + def _write_atomic(self, data: dict[str, Any]) -> None: + # tempfile in same dir so os.replace is atomic on the same filesystem + fd, tmp = tempfile.mkstemp(prefix=".state.", suffix=".tmp", dir=str(self.path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, self.path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/scripts/core/token_estimator.py b/scripts/core/token_estimator.py new file mode 100644 index 0000000..7a61372 --- /dev/null +++ b/scripts/core/token_estimator.py @@ -0,0 +1,53 @@ +"""Token-usage estimation for the active chain of a JSONL transcript. + +v0.1 ships only `char_div_3_5` — count characters of the active chain's content +fields and divide by 3.5. This is a rough but cheap heuristic; tiktoken-based +estimation can be added later behind the same interface. +""" +from __future__ import annotations +import json +from pathlib import Path +from typing import Any + +from .jsonl_ops import find_active_chain, load_messages + + +def _char_count(content: Any) -> int: + if content is None: + return 0 + if isinstance(content, str): + return len(content) + if isinstance(content, list): + total = 0 + for item in content: + if isinstance(item, dict): + if "text" in item and isinstance(item["text"], str): + total += len(item["text"]) + elif "input" in item: + total += len(json.dumps(item.get("input", {}), ensure_ascii=False)) + elif "content" in item: + total += _char_count(item["content"]) + else: + total += len(str(item)) + return total + if isinstance(content, dict): + return _char_count(content.get("content")) + return len(str(content)) + + +def _message_chars(msg: dict[str, Any]) -> int: + inner = msg.get("message") + if isinstance(inner, dict): + return _char_count(inner.get("content")) + if "tool_use_id" in msg and "content" in msg: + return _char_count(msg.get("content")) + return 0 + + +def estimate_active_chain_tokens(transcript: Path, *, method: str = "char_div_3_5") -> int: + if method != "char_div_3_5": + raise ValueError(f"Unsupported estimate_method: {method}") + msgs = load_messages(transcript) + chain = find_active_chain(msgs) + chars = sum(_message_chars(m) for m in chain) + return int(chars / 3.5) diff --git a/scripts/on_pre_compact.py b/scripts/on_pre_compact.py new file mode 100644 index 0000000..b17f829 --- /dev/null +++ b/scripts/on_pre_compact.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""PreCompact hook — block Claude Code's built-in auto-compact. + +Only active in projects that have opted in (AirContext/ exists). Otherwise +Claude's default auto-compact behaviour is preserved untouched. + +Manual `/compact` is always allowed; auto-triggered compaction is rejected so +AirContext's rule-driven compactor is the only thing that mutates the chain. +""" +from __future__ import annotations +import json +import os +import sys +from pathlib import Path + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + + cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + if not (Path(cwd) / "AirContext").exists(): + return 0 # project hasn't opted in; let Claude do whatever it wants + + trigger = payload.get("trigger") or payload.get("compact_trigger") + if trigger == "auto": + print(json.dumps({ + "decision": "block", + "reason": ( + "AirContext: auto-compact disabled by user policy. " + "Custom rule-driven compaction handles this out-of-band." + ) + })) + return 0 + return 0 # manual /compact passes through + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/on_session_start.py b/scripts/on_session_start.py new file mode 100644 index 0000000..e094040 --- /dev/null +++ b/scripts/on_session_start.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""SessionStart hook — verify AirContext/ presence and surface a friendly notice. + +Stage-2 implementation. For now we: + - look for AirContext/ in cwd + - if absent or invalid, set state.config_missing=true so UserPromptSubmit can block + - emit a SessionStart additionalContext message describing AirContext status +""" +from __future__ import annotations +import json +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def _find_project_root(stdin_payload: dict) -> Path: + cwd = stdin_payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + return Path(cwd) + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + + project = _find_project_root(payload) + air = project / "AirContext" + + # Project hasn't opted in — stay completely silent so AirContext doesn't + # pollute every Claude Code session globally. + if not air.exists(): + return 0 + + config = air / "config.yaml" + state = StateFile(air / "state.json") + + sid = payload.get("session_id") + if sid: + state.update(last_session_id=sid) + + if not config.exists(): + state.update(config_missing=True) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": ( + "AirContext: AirContext/ exists but config.yaml is missing. " + "Run `/aircontext-init` to (re)install templates." + ) + } + })) + return 0 + + err = validate_config(config) + if err: + state.update(config_missing=True) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": f"AirContext config invalid: {err}" + } + })) + return 0 + + state.update(config_missing=False) + # Healthy — stay silent to keep prompt clean. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/on_tool_use.py b/scripts/on_tool_use.py new file mode 100644 index 0000000..42631b9 --- /dev/null +++ b/scripts/on_tool_use.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""PostToolUse hook — lightweight ticker. + +Responsibilities (all must complete in well under 100 ms): + 1. Read AirContext/state.json. If paused, in-progress, or in cooldown, return. + 2. Estimate active-chain token usage from transcript_path. + 3. If usage / model_context_window >= trigger.threshold, fork compactor.py + in the background and return immediately. + +The compactor runs detached so it never blocks Claude's main loop. +""" +from __future__ import annotations +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import load_config # noqa: E402 +from core.state import StateFile # noqa: E402 +from core.token_estimator import estimate_active_chain_tokens # noqa: E402 + + +def _project_root(payload: dict) -> Path: + cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + return Path(cwd) + + +def _spawn_compactor(project: Path, transcript: Path, session_id: str) -> None: + """Detach compactor.py so the hook returns immediately.""" + compactor = _HERE / "core" / "compactor.py" + args = [sys.executable, str(compactor), + "--project", str(project), + "--transcript", str(transcript), + "--session-id", session_id] + log_dir = project / "AirContext" / "snapshots" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "compactor.log" + + if os.name == "nt": + DETACHED = 0x00000008 # DETACHED_PROCESS + NEW_GROUP = 0x00000200 + creationflags = DETACHED | NEW_GROUP + with log_file.open("ab") as lf: + subprocess.Popen(args, stdout=lf, stderr=lf, stdin=subprocess.DEVNULL, + creationflags=creationflags, close_fds=True) + else: + with log_file.open("ab") as lf: + subprocess.Popen(args, stdout=lf, stderr=lf, stdin=subprocess.DEVNULL, + start_new_session=True, close_fds=True) + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return 0 + + project = _project_root(payload) + air = project / "AirContext" + config_path = air / "config.yaml" + if not config_path.exists(): + return 0 # uninitialised — nothing to do + + state = StateFile(air / "state.json") + snap = state.read() + if snap.get("paused") or snap.get("compaction_in_progress") or snap.get("compaction_ready"): + return 0 + + try: + cfg = load_config(config_path) + except Exception: + return 0 # config broken — UserPromptSubmit will surface it + + cooldown = int(cfg.get("trigger", {}).get("cooldown_seconds", 300)) + if time.time() - snap.get("last_compaction_unix", 0) < cooldown: + return 0 + + transcript = payload.get("transcript_path") + if not transcript or not Path(transcript).exists(): + return 0 + session_id = payload.get("session_id") or snap.get("last_session_id") + if not session_id: + return 0 + + threshold = float(cfg.get("trigger", {}).get("threshold", 0.6)) + window = int(cfg.get("trigger", {}).get("model_context_window", 200000)) + method = cfg.get("trigger", {}).get("estimate_method", "char_div_3_5") + + used = estimate_active_chain_tokens(Path(transcript), method=method) + if used / max(window, 1) < threshold: + return 0 + + # Mark in-progress immediately so successive PostToolUse calls don't double-fire. + state.update(compaction_in_progress=True, last_session_id=session_id) + _spawn_compactor(project, Path(transcript), session_id) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/on_user_prompt.py b/scripts/on_user_prompt.py new file mode 100644 index 0000000..ea9eaff --- /dev/null +++ b/scripts/on_user_prompt.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""UserPromptSubmit hook — block prompts until AirContext config is valid. + +When state.config_missing is true, refuse the prompt with a guidance message. +Otherwise, pass through silently. +""" +from __future__ import annotations +import json +import os +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE)) + +from core.config_loader import validate_config # noqa: E402 +from core.state import StateFile # noqa: E402 + + +def _project_root(payload: dict) -> Path: + cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + return Path(cwd) + + +def main() -> int: + raw = sys.stdin.read() or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + + project = _project_root(payload) + air = project / "AirContext" + + # Project hasn't opted in — silently allow. Users opt in by running + # `aircontext` (which installs templates) or `/aircontext-init`. + if not air.exists(): + return 0 + + config = air / "config.yaml" + if not config.exists(): + # Opted in but template install never completed. + msg = ( + "AirContext: AirContext/ directory exists but config.yaml is missing. " + "Run `/aircontext-init` to reinstall templates, or remove the AirContext/ " + "directory if you no longer want compaction in this project." + ) + print(json.dumps({"decision": "block", "reason": msg})) + return 0 + + err = validate_config(config) + if err: + msg = ( + f"AirContext config is invalid: {err}\n" + "Edit AirContext/config.yaml and resubmit." + ) + print(json.dumps({"decision": "block", "reason": msg})) + return 0 + + # Healthy — clear stale flag and pass through. + state = StateFile(air / "state.json") + if state.read().get("config_missing"): + state.update(config_missing=False) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..a01207e --- /dev/null +++ b/templates/README.md @@ -0,0 +1,26 @@ +# AirContext (per-project) + +This directory configures AirContext for **this project**. It was created the +first time you ran `aircontext` here. + +## Files + +- `config.yaml` — backend (LLM), trigger threshold, compaction options +- `rules.md` — compression rules sent to the LLM as system prompt +- `state.json` — runtime state (do not edit; managed by the plugin) +- `snapshots/` — backup of each JSONL before compaction; keep or delete freely + +## Getting started + +1. Open `config.yaml`, set `backend.endpoint` / `backend.model` / `backend.api_key`. + The default targets DeepSeek; replace with Ollama or any OpenAI-compatible + server as needed. For Ollama set `endpoint: http://localhost:11434/v1` and + any non-empty `api_key`. +2. Tune `trigger.threshold` (default 0.6 = compact at 60% of model context). +3. Edit `rules.md` to bias summaries toward what your project considers important. +4. Re-run `aircontext` from this directory. + +## Disabling auto compaction temporarily + +Run `/aircontext-pause` inside Claude Code, or set `safety.dry_run: true` in +config.yaml. diff --git a/templates/config.yaml b/templates/config.yaml new file mode 100644 index 0000000..f5e609a --- /dev/null +++ b/templates/config.yaml @@ -0,0 +1,44 @@ +# AirContext compaction config. +# +# YOU MUST FILL these two fields (privacy-sensitive, never auto-populated): +# - backend.endpoint your LLM endpoint URL +# - backend.api_key either a literal value, or the ${env:VAR} placeholder +# pointing at an env var that holds the secret +# +# Everything else is auto-configured by `aircontext` on first run +# (model_context_window inferred from your Claude model setting, etc.) and +# you generally don't need to touch it. Edit freely if you want to override. +# `${env:VAR}` placeholders are resolved at runtime against environment +# variables (including those declared in ~/.claude/settings.json `env` block). + +backend: + type: anthropic_native # or openai_compat (auto-set by aircontext when possible) + + # >>> REQUIRED — fill before re-running aircontext <<< + endpoint: "" # e.g. https://api.anthropic.com | https://wolfai.top | http://localhost:11434/v1 + + # >>> REQUIRED — fill or set the env var <<< + api_key: ${env:ANTHROPIC_AUTH_TOKEN} # change to ${env:OPENAI_API_KEY} or paste a literal value + + model: claude-haiku-4-5-20251001 # cheap+fast for compression; raise to claude-sonnet-4-6 if quality insufficient + max_output_tokens: 4000 + timeout_seconds: 60 + anthropic_version: "2023-06-01" # only used when type = anthropic_native + +trigger: + strategy: token_ratio + threshold: 0.6 # compact when active chain reaches 60% of model_context_window + cooldown_seconds: 300 + estimate_method: char_div_3_5 + model_context_window: 200000 # auto-overridden on first aircontext run based on your claude model + +compaction: + preserve_tail_messages: 10 + drop_tool_results_over_lines: 1000 + rules_file: rules.md + continuation_prompt: "基于上面的压缩摘要继续之前的工作;如果没有进行中的任务则等待我的下一条指令。" + +safety: + backup: true + max_snapshots: 50 + dry_run: false diff --git a/templates/rules.md b/templates/rules.md new file mode 100644 index 0000000..a641e01 --- /dev/null +++ b/templates/rules.md @@ -0,0 +1,30 @@ +# AirContext Compression Rules + +This file is fed to the compression LLM as part of its system prompt. Edit +freely to bias what the summary keeps versus drops. The defaults below favour +software-engineering sessions; rewrite them for your domain. + +## Always preserve verbatim + +- File paths read or modified, with the final intended state of each file +- Architecture decisions and the reasoning behind them +- Open TODOs, unresolved bugs, error messages still in scope +- The user's stated goal for the current session +- Any user-supplied facts that the model could not derive from the codebase + (credentials hints, deployment quirks, deadlines, "we tried X and it failed because Y") + +## Aggressively drop + +- Exploratory grep/glob results that did not lead anywhere +- File contents that were superseded by later edits +- Tool outputs over 1000 lines (keep first 50 lines and last 50 lines, summarise the middle) +- Repeated similar searches and their near-identical outputs +- Completed sub-steps whose only output was "looks good, moving on" + +## Output format + +- Plain prose, no markdown headers or bullet lists unless they materially aid recall +- Reference files by `path:line` when relevant +- One paragraph per topic; aim for under 3000 tokens total +- Do NOT speculate beyond what the conversation contains +- Do NOT apologise, summarise the act of summarising, or add meta-commentary