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

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