Initial release: aircontext
This commit is contained in:
13
.claude-plugin/marketplace.json
Normal file
13
.claude-plugin/marketplace.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
58
.claude-plugin/plugin.json
Normal file
58
.claude-plugin/plugin.json
Normal 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
94
README.md
Normal 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
164
bin/aircontext
Normal 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
4
bin/aircontext.cmd
Normal 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" %*
|
||||||
12
commands/aircontext-init.md
Normal file
12
commands/aircontext-init.md
Normal 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"
|
||||||
|
```
|
||||||
14
commands/aircontext-now.md
Normal file
14
commands/aircontext-now.md
Normal 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}"
|
||||||
|
```
|
||||||
13
commands/aircontext-pause.md
Normal file
13
commands/aircontext-pause.md
Normal 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
|
||||||
|
```
|
||||||
8
commands/aircontext-status.md
Normal file
8
commands/aircontext-status.md
Normal 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
11
pyproject.toml
Normal 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
30
scripts/cmd_init.py
Normal 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
73
scripts/cmd_now.py
Normal 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
38
scripts/cmd_pause.py
Normal 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
57
scripts/cmd_status.py
Normal 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
0
scripts/core/__init__.py
Normal file
149
scripts/core/auto_init.py
Normal file
149
scripts/core/auto_init.py
Normal 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
|
||||||
0
scripts/core/backends/__init__.py
Normal file
0
scripts/core/backends/__init__.py
Normal file
91
scripts/core/backends/anthropic_native.py
Normal file
91
scripts/core/backends/anthropic_native.py
Normal 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}")
|
||||||
19
scripts/core/backends/base.py
Normal file
19
scripts/core/backends/base.py
Normal 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}")
|
||||||
77
scripts/core/backends/openai_compat.py
Normal file
77
scripts/core/backends/openai_compat.py
Normal 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
260
scripts/core/compactor.py
Normal 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())
|
||||||
97
scripts/core/config_loader.py
Normal file
97
scripts/core/config_loader.py
Normal 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
158
scripts/core/jsonl_ops.py
Normal 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
93
scripts/core/state.py
Normal 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
|
||||||
53
scripts/core/token_estimator.py
Normal file
53
scripts/core/token_estimator.py
Normal 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
42
scripts/on_pre_compact.py
Normal 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())
|
||||||
79
scripts/on_session_start.py
Normal file
79
scripts/on_session_start.py
Normal 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
106
scripts/on_tool_use.py
Normal 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
68
scripts/on_user_prompt.py
Normal 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
26
templates/README.md
Normal 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
44
templates/config.yaml
Normal 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
30
templates/rules.md
Normal 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
|
||||||
Reference in New Issue
Block a user