Initial release: aircontext
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user