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

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