Initial release: aircontext

This commit is contained in:
admin
2026-05-18 11:45:08 +08:00
commit 5914cfb9cd
32 changed files with 1981 additions and 0 deletions

View File

@@ -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."
}
]
}

View File

@@ -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"
}

94
README.md Normal file
View File

@@ -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 <id>` 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/<ts>-<sid>.jsonl
│ • append [summary, continuation] with parentUuid=null
│ • set state.compaction_ready = true
◄──────────┘
wrapper watcher sees ready → SIGTERM claude → spawn `claude --resume <id>`
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@<your-marketplace>
# or directly via settings.json
{
"extraKnownMarketplaces": {
"aircontext-mkt": { "source": { "source": "github", "repo": "<you>/aircontext-plugin" } }
},
"enabledPlugins": { "aircontext@aircontext-mkt": true }
}
```
Requires Python ≥ 3.10 and `pyyaml`. The wrapper assumes `claude` is on PATH.
## Use
```bash
cd <your-project>
aircontext # instead of `claude`
```
First run creates `<project>/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 <id>` 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

164
bin/aircontext Normal file
View File

@@ -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 <session_id>` 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())

4
bin/aircontext.cmd Normal file
View File

@@ -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" %*

View File

@@ -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"
```

View File

@@ -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 <session-id>` yourself for the new chain to take effect.
```!
python "${CLAUDE_PLUGIN_ROOT}/scripts/cmd_now.py" --session-id "${CLAUDE_SESSION_ID}"
```

View File

@@ -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
```

View File

@@ -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"
```

11
pyproject.toml Normal file
View File

@@ -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"]

30
scripts/cmd_init.py Normal file
View File

@@ -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())

73
scripts/cmd_now.py Normal file
View File

@@ -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/<encoded-cwd>/sessions/<session-id>.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())

38
scripts/cmd_pause.py Normal file
View File

@@ -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())

57
scripts/cmd_status.py Normal file
View File

@@ -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())

0
scripts/core/__init__.py Normal file
View File

149
scripts/core/auto_init.py Normal file
View File

@@ -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

View File

View File

@@ -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}")

View File

@@ -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}")

View File

@@ -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()

260
scripts/core/compactor.py Normal file
View File

@@ -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 <root> --transcript <jsonl> --session-id <sid>
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())

View File

@@ -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

158
scripts/core/jsonl_ops.py Normal file
View File

@@ -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 <id>` 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

93
scripts/core/state.py Normal file
View File

@@ -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

View File

@@ -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)

42
scripts/on_pre_compact.py Normal file
View File

@@ -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())

View File

@@ -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())

106
scripts/on_tool_use.py Normal file
View File

@@ -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())

68
scripts/on_user_prompt.py Normal file
View File

@@ -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())

26
templates/README.md Normal file
View File

@@ -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.

44
templates/config.yaml Normal file
View File

@@ -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

30
templates/rules.md Normal file
View File

@@ -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