chad-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chad/cli.py ADDED
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env python3
2
+ """chad — a local, MLX-backed, Claude-Code-style coding agent.
3
+
4
+ One model (Ornith — 35B on big Macs, 9B on small), one entrypoint, run with uv:
5
+
6
+ uv run chad # interactive full-screen TUI
7
+ uv run chad "fix the bug in greet.py" # one-shot, headless
8
+ uv run chad -c # resume this directory's conversation
9
+
10
+ Rare long-session knobs live in env vars — see README "Advanced".
11
+ """
12
+ import argparse
13
+ import os
14
+ import platform
15
+ import subprocess
16
+ import sys
17
+
18
+ from . import config
19
+ from .agent import Agent, repl
20
+ from .engine import Engine
21
+
22
+ # Package dir is src/chad/; the project root (two levels up) is the dev clone. If a
23
+ # locally-built weights tree exists at <root>/models/ it's preferred (see _pick_model);
24
+ # otherwise — the normal case — the default model ships from Hugging Face and downloads
25
+ # into the shared HF cache on first use. Point CHAD_MODEL at any local dir to override.
26
+ _HERE = os.path.dirname(os.path.abspath(__file__))
27
+ _PROJECT_ROOT = os.path.dirname(os.path.dirname(_HERE))
28
+
29
+ # The shipped models, on Hugging Face. Naming follows Unsloth's dynamic-quant
30
+ # convention so the quant scheme is recognizable (UD = Unsloth Dynamic; Q2_K_XL =
31
+ # 2-bit experts with a high-bit backbone/router), plus an -MLX suffix for format
32
+ # discoverability. The quant itself is MLX group-64 affine, not llama.cpp Q2_K
33
+ # k-quants — the model card says so; the tag is for recognition, not bit-for-bit
34
+ # equivalence.
35
+ _HF_35B = "nathansutton/Ornith-1.0-35B-UD-Q2_K_XL-MLX" # default: 35B MoE, ~12 GB resident
36
+ _HF_9B = "nathansutton/Ornith-1.0-9B-UD-Q4_K_XL-MLX" # low-RAM fallback, ~5 GB resident
37
+ # A dev clone that already built the weights locally should use them rather than
38
+ # re-download — prefer these dirs when present.
39
+ _LOCAL_35B = os.path.join(_PROJECT_ROOT, "models", "Ornith-1.0-35B-dyn2-q2-awq")
40
+ _LOCAL_9B = os.path.join(_PROJECT_ROOT, "models", "Ornith-1.0-9B-4bit-awq")
41
+ # The 35B (2-bit experts / 6-bit backbone) is ~12 GB resident + KV + runtime ≈ 14 GB
42
+ # peak. Comfortable at ≥24 GB; on 16/18 GB MacBook Pros the default Metal wired limit
43
+ # (~2/3 RAM) sits below that, so we fall back to the 9B (fits easily) automatically.
44
+ _BIG_RAM_GB = 23.5
45
+
46
+
47
+ # These are the STRICT siblings of config.env_int/env_float: a non-numeric value raises
48
+ # (int()/float() propagate) rather than warning-and-defaulting. test_cli.py pins that
49
+ # contract, and a garbled CHAD_MAX_CONTEXT/CHAD_KV_BITS should fail loud at startup rather
50
+ # than silently reverting to the model default. Kept inline here on purpose; the lenient
51
+ # config helpers back the mid-run budget knobs in agent.py instead.
52
+ def _env_int(name):
53
+ val = os.environ.get(name)
54
+ return int(val) if val else None
55
+
56
+
57
+ def _env_float(name):
58
+ val = os.environ.get(name)
59
+ return float(val) if val else None
60
+
61
+
62
+ def _version_string():
63
+ """chad <version> (<vcs commit>) — commit resolves for git installs via
64
+ dist-info/direct_url.json, or from the dev clone's .git; absent otherwise."""
65
+ from . import __version__
66
+ detail = ""
67
+ try:
68
+ import json
69
+ from importlib.metadata import distribution
70
+ raw = distribution("chad").read_text("direct_url.json") or ""
71
+ commit = json.loads(raw).get("vcs_info", {}).get("commit_id", "") if raw else ""
72
+ if not commit and os.path.isdir(os.path.join(_PROJECT_ROOT, ".git")):
73
+ commit = subprocess.check_output(
74
+ ["git", "-C", _PROJECT_ROOT, "rev-parse", "--short", "HEAD"],
75
+ text=True, stderr=subprocess.DEVNULL).strip()
76
+ if commit:
77
+ detail = f" ({commit[:12]})"
78
+ except Exception: # noqa: BLE001 — version detail is best-effort, never fatal
79
+ pass
80
+ return f"chad {__version__}{detail}"
81
+
82
+
83
+ def ram_aware_ctx_limit(eff_ctx, budget_bytes, active_bytes, kv_bytes_per_token,
84
+ reserve_gb=1.5, safety=0.90, gen_margin=2048, floor=8192):
85
+ """Plan 036: largest prompt-token budget (= the compaction trigger) that keeps the
86
+ growing KV cache inside a safe slice of the Metal *recommended working set*, given the
87
+ model's already-resident footprint and the *measured* per-token KV cost. Pure +
88
+ measured — replaces the magic `CTX_CAP = 120_000`, which was an OOM guard set blind to
89
+ the real per-token cost (it over-compacts: on a 24 GB M4 Pro the 20 KiB/token hybrid
90
+ cache fits ~175 k tokens, not 120 k).
91
+
92
+ `budget*safety` leaves a headroom band below Apple's recommendation; subtract the
93
+ resident model+SSM floor (`active_bytes`) and a scratch reserve for prefill/decode
94
+ buffers to get the bytes free for KV; divide by `kv_bytes_per_token` for the token
95
+ ceiling. Capped at `eff_ctx − gen_margin` (the model's real window) and floored so a
96
+ tight box still gets a usable window. Self-calibrates per machine (16 GB → small,
97
+ 64 GB → near the window). Returns None if inputs are unusable so the caller can keep
98
+ the old fixed cap."""
99
+ if not (budget_bytes and kv_bytes_per_token and active_bytes):
100
+ return None
101
+ usable = budget_bytes * safety - active_bytes - reserve_gb * 1e9
102
+ if usable <= 0:
103
+ return floor
104
+ ram_ctx = int(usable / kv_bytes_per_token)
105
+ return max(floor, min(eff_ctx - gen_margin, ram_ctx))
106
+
107
+
108
+ def _compute_ctx_limit(eng):
109
+ """The auto-compaction threshold for a loaded engine. On this non-trimmable hybrid
110
+ cache, compaction forces a full body re-prefill (plan 035: ~79 % of all prefill), so
111
+ we compact as rarely as RAM safely allows: size the trigger from the live Metal
112
+ budget + the model's *measured* per-token KV cost (plan 036) instead of a blind 120 k
113
+ cap that over-compacts. CHAD_CTX_LIMIT still wins (evals/tests); CHAD_CTX_RESERVE_GB
114
+ tunes the scratch headroom. Falls back to the old fixed cap if the memory APIs or the
115
+ KV measurement are unavailable. Needs eng.load() to have run (reads effective_ctx +
116
+ kv_bytes_per_token)."""
117
+ ctx_limit = _env_int("CHAD_CTX_LIMIT")
118
+ if not ctx_limit:
119
+ try:
120
+ import mlx.core as mx
121
+ ctx_limit = ram_aware_ctx_limit(
122
+ eng.effective_ctx,
123
+ mx.device_info()["max_recommended_working_set_size"],
124
+ mx.get_active_memory(), eng.kv_bytes_per_token,
125
+ reserve_gb=_env_float("CHAD_CTX_RESERVE_GB") or 1.5)
126
+ except Exception: # noqa: BLE001 — never let memory probing break startup
127
+ ctx_limit = None
128
+ if not ctx_limit:
129
+ ctx_limit = min(max(4096, eng.effective_ctx - 2048), 120_000) # old fixed cap
130
+ return ctx_limit
131
+
132
+
133
+ def _preflight():
134
+ """chad runs only on Apple Silicon — MLX has no CPU/CUDA build. Hard-stop with a
135
+ human message instead of letting `uv sync`/import fail cryptically elsewhere."""
136
+ if platform.system() != "Darwin" or platform.machine() != "arm64":
137
+ sys.stderr.write(
138
+ "chad: requires an Apple Silicon Mac (arm64 macOS).\n"
139
+ f" detected: {platform.system()} {platform.machine() or '?'}\n"
140
+ " MLX ships no CPU/CUDA build — there is no supported non-Apple path.\n")
141
+ sys.exit(1)
142
+
143
+
144
+ def _detect_ram_gb():
145
+ """Physical RAM in GiB via sysctl, or None if it can't be read."""
146
+ try:
147
+ out = subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True)
148
+ return int(out.strip()) / (1024 ** 3)
149
+ except Exception: # noqa: BLE001 — any failure → caller picks the safe (smaller) model
150
+ return None
151
+
152
+
153
+ def _pick_model():
154
+ """Resolve the model id and a human label for *why* it was chosen.
155
+
156
+ Order: explicit CHAD_MODEL override → RAM-aware default (35B on big boxes, 9B on
157
+ small) → prefer a locally-built models/ dir over the HF repo when it exists.
158
+ """
159
+ env = config.env_str("CHAD_MODEL")
160
+ if env:
161
+ return env, "CHAD_MODEL override"
162
+ ram = _detect_ram_gb()
163
+ if ram is not None and ram < _BIG_RAM_GB:
164
+ local, repo = _LOCAL_9B, _HF_9B
165
+ why = f"9B (default; {ram:.0f} GB RAM < {_BIG_RAM_GB:.0f} GB, 35B would be tight)"
166
+ else:
167
+ local, repo = _LOCAL_35B, _HF_35B
168
+ why = "35B (default)"
169
+ return (local if os.path.isdir(local) else repo), why
170
+
171
+
172
+ def _ensure_model(model_id):
173
+ """If model_id is a HF repo id not yet in the local cache, confirm and download it
174
+ into ~/.cache/huggingface (shared, resumable, paid once per machine). Local dirs
175
+ and already-cached repos return immediately. Headless (no TTY) auto-downloads."""
176
+ if os.path.isdir(model_id):
177
+ return # a local path — nothing to fetch
178
+ from huggingface_hub import snapshot_download, try_to_load_from_cache
179
+ if isinstance(try_to_load_from_cache(model_id, "config.json"), str):
180
+ return # already in the HF cache
181
+ size = "~12 GB" if "35B" in model_id else "~5 GB"
182
+ sys.stderr.write(
183
+ f"\nchad needs the model '{model_id}' "
184
+ f"({size} — minutes on fast fiber, ~20 min on 100 Mbit; resumable).\n"
185
+ "It downloads once into ~/.cache/huggingface and is reused across projects.\n")
186
+ if sys.stdin.isatty():
187
+ ans = input("Download now? [Y/n] ").strip().lower()
188
+ if ans and ans not in ("y", "yes"):
189
+ sys.stderr.write(
190
+ "Aborted. Set CHAD_MODEL to a local model dir to skip the download.\n")
191
+ sys.exit(1)
192
+ else:
193
+ sys.stderr.write("[headless: downloading automatically]\n")
194
+ try:
195
+ snapshot_download(model_id) # tqdm progress to stderr
196
+ except Exception as e: # noqa: BLE001 — offline / gated / typo'd repo → guidance, not a traceback
197
+ sys.stderr.write(
198
+ f"\nchad: could not download '{model_id}'\n"
199
+ f" cause: {type(e).__name__}: {e}\n"
200
+ " fix: check your connection; if the repo is gated, run `hf auth login`.\n"
201
+ " Or point CHAD_MODEL at a local model dir you've already built.\n")
202
+ sys.exit(1)
203
+
204
+
205
+ def _fail_model_load(model_id, err):
206
+ """Turn a raw model-load traceback into problem / cause / fix and exit."""
207
+ sys.stderr.write(f"\nchad: could not load model '{model_id}'\n")
208
+ sys.stderr.write(f" cause: {type(err).__name__}: {err}\n")
209
+ if os.path.isdir(model_id):
210
+ sys.stderr.write(
211
+ " fix: the local model dir looks incomplete or corrupt. Re-build it, or\n"
212
+ " unset CHAD_MODEL to fall back to the Hugging Face download.\n")
213
+ else:
214
+ sys.stderr.write(
215
+ " fix: a partial/corrupt download or not enough free RAM. Re-run (the HF\n"
216
+ f" download resumes), or try the smaller model: CHAD_MODEL={_HF_9B}\n")
217
+ sys.exit(1)
218
+
219
+
220
+ def _maybe_home_dir_note():
221
+ """chad snapshots the working directory into context at startup, so the home dir is
222
+ rarely the intended workspace. Nudge once — no exit, no behavior change. Home-dir
223
+ only: guessing "is this a project" from marker files false-positives on legit
224
+ non-git work dirs."""
225
+ if os.getcwd() == os.path.expanduser("~"):
226
+ sys.stderr.write(
227
+ "note: running in your home directory — chad works best inside a project "
228
+ "(cd into one and rerun).\n")
229
+
230
+
231
+ def _pick_session(items):
232
+ """Prompt the user to pick one of `items` (from session.list_sessions) by number.
233
+ Returns the chosen item, or None to start fresh. Requires a TTY — the caller
234
+ guards that before calling."""
235
+ from . import session
236
+ sys.stderr.write("Resume which session? (this directory's recent sessions)\n")
237
+ for i, it in enumerate(items, 1):
238
+ sys.stderr.write(f" {i}. {session.describe(it)}\n")
239
+ try:
240
+ raw = input("session number (blank to cancel): ").strip()
241
+ except (EOFError, KeyboardInterrupt):
242
+ return None
243
+ if not raw:
244
+ return None
245
+ try:
246
+ n = int(raw)
247
+ except ValueError:
248
+ sys.stderr.write("not a number; starting fresh\n")
249
+ return None
250
+ if 1 <= n <= len(items):
251
+ return items[n - 1]
252
+ sys.stderr.write("out of range; starting fresh\n")
253
+ return None
254
+
255
+
256
+ def main():
257
+ ap = argparse.ArgumentParser(
258
+ prog="chad",
259
+ description="Local MLX-backed coding agent (Ornith). Run with `uv run chad`.",
260
+ )
261
+ ap.add_argument("--version", action="version", version=_version_string())
262
+ ap.add_argument("task", nargs="?",
263
+ help="one-shot task to run headless and exit; omit for the interactive TUI")
264
+ ap.add_argument("-c", "--continue", dest="cont", action="store_true",
265
+ help="resume the most recent saved conversation for this directory")
266
+ ap.add_argument("--resume", action="store_true",
267
+ help="list this directory's recent sessions and pick one by number "
268
+ "(resuming forks: the picked session is never overwritten)")
269
+ ap.add_argument("--plan", action="store_true",
270
+ help="start in read-only plan mode (investigate and propose, no edits)")
271
+ ap.add_argument("--yolo", action="store_true",
272
+ help="auto-approve bash/write/edit (skip confirmation prompts)")
273
+ ap.add_argument("--no-think", action="store_true",
274
+ help="skip the model's <think> reasoning blocks (faster)")
275
+ ap.add_argument("--think-budget", type=int, default=None, dest="think_budget",
276
+ help="soft-cap each step's <think> run at N tokens, then force-close "
277
+ "it and continue (escalates when stuck); off by default. Also "
278
+ "settable via CHAD_THINK_BUDGET.")
279
+ ap.add_argument("--turn-budget-tokens", type=int, default=None, dest="turn_budget_tokens",
280
+ help="runaway-turn governor: end a turn once it has burned N cumulative "
281
+ "prefill tokens WITHOUT landing+verifying a change (nudges at ~50%%, "
282
+ "banks a progress note and stops at ~80%%). Defaults to 3× the context "
283
+ "limit; CHAD_NO_GOVERNOR=1 disables. Also CHAD_TURN_BUDGET_TOKENS.")
284
+ ap.add_argument("--turn-budget-s", type=float, default=None, dest="turn_budget_s",
285
+ help="wall-clock variant of --turn-budget-tokens (seconds); off by "
286
+ "default (interactive: the human is the wall clock). Also "
287
+ "CHAD_TURN_BUDGET_S.")
288
+ ap.add_argument("--auto-continue", type=int, default=0, dest="auto_continue",
289
+ help="on a one-shot run, if the governor hard-stops a turn, relaunch a "
290
+ "FRESH turn (cleared context) seeded with the progress note, up to N "
291
+ "times (default 0 = off). Sheds the ramble and the huge prefill.")
292
+ # Backend selection (plan 046 spike). Default 'mlx' is the in-process engine and the
293
+ # whole point of chad; 'openai' drives the SAME harness against an OpenAI-compatible
294
+ # endpoint to separate harness-value from engine-value (honest degradations apply —
295
+ # see openai_engine.py). The MLX path below is untouched when --backend is unset.
296
+ ap.add_argument("--backend", choices=("mlx", "openai"), default="mlx",
297
+ help="inference backend: 'mlx' (default, in-process KV cache) or "
298
+ "'openai' (spike: run the chad harness against an OpenAI-compatible "
299
+ "/v1/chat/completions endpoint; requires --base-url).")
300
+ ap.add_argument("--base-url", dest="base_url", default=None,
301
+ help="OpenAI-compatible base URL for --backend openai (e.g. "
302
+ "http://localhost:8080/v1). Also CHAD_OPENAI_BASE_URL.")
303
+ ap.add_argument("--api-key-env", dest="api_key_env", default=None,
304
+ help="name of the env var holding the API key for --backend openai; the "
305
+ "key is read from that var, never passed on the command line.")
306
+ ap.add_argument("--repl", action="store_true",
307
+ help="plain line REPL instead of the full-screen TUI")
308
+ # Back-compat: -p/--prompt was the old one-shot spelling, now the positional task.
309
+ ap.add_argument("-p", "--prompt", dest="prompt_flag", help=argparse.SUPPRESS)
310
+ args = ap.parse_args()
311
+
312
+ _preflight() # Apple Silicon only — fail clearly before importing/loading MLX
313
+ # --think-budget (plan 039) reaches the TUI/REPL Agents through the same env knob
314
+ # their __init__ reads, so the flag works on every entrypoint, not just headless.
315
+ if args.think_budget is not None:
316
+ os.environ["CHAD_THINK_BUDGET"] = str(args.think_budget)
317
+ # --turn-budget-* (plan 040) reach the TUI/REPL Agents through the same env knobs
318
+ # their __init__ reads, so the governor is configurable on every entrypoint.
319
+ if args.turn_budget_tokens is not None:
320
+ os.environ["CHAD_TURN_BUDGET_TOKENS"] = str(args.turn_budget_tokens)
321
+ if args.turn_budget_s is not None:
322
+ os.environ["CHAD_TURN_BUDGET_S"] = str(args.turn_budget_s)
323
+ task = args.task or args.prompt_flag
324
+ # Ornith; no draft, ever. RAM-aware default, local-dir-preferred, HF fallback.
325
+ model_id, why = _pick_model()
326
+
327
+ # Advanced, rarely-touched knobs live in env vars to keep the CLI sane:
328
+ # CHAD_MAX_CONTEXT YaRN-extend the window (e.g. 131072 for 128k)
329
+ # CHAD_CTX_LIMIT prompt-token budget before old tool outputs compact
330
+ # CHAD_KV_BITS quantize the KV cache (e.g. 8) to save RAM on long runs
331
+ max_context = _env_int("CHAD_MAX_CONTEXT")
332
+ kv_bits = _env_int("CHAD_KV_BITS")
333
+
334
+ # ds4-style on-disk KV warm-start of the stable system+tools prefix.
335
+ cache_dir = os.path.expanduser("~/.cache/chad/kv")
336
+
337
+ if args.backend == "openai":
338
+ # Plan 046 spike: drive the chad harness against an OpenAI-compatible endpoint
339
+ # instead of the in-process MLX engine. Only a tokenizer is loaded locally (to
340
+ # render/decode prompts); generation is proxied over HTTP. Weights are NOT
341
+ # downloaded (we don't run them here). The MLX default path is untouched.
342
+ from .openai_engine import OpenAIEngine
343
+ base_url = args.base_url or config.env_str("CHAD_OPENAI_BASE_URL")
344
+ if not base_url:
345
+ sys.stderr.write("chad --backend openai needs --base-url (or CHAD_OPENAI_BASE_URL), "
346
+ "e.g. http://localhost:8080/v1\n")
347
+ sys.exit(1)
348
+ api_key = os.environ.get(args.api_key_env, "") if args.api_key_env else ""
349
+ eng = OpenAIEngine(model_id=model_id, base_url=base_url, api_key=api_key,
350
+ effective_ctx=max_context or 32768)
351
+ sys.stderr.write(f"backend=openai · base_url={base_url} · model={model_id} "
352
+ f"(tokenizer local, generation proxied) ...\n")
353
+ else:
354
+ _ensure_model(model_id) # first-run download-on-consent if it's an uncached HF repo
355
+ eng = Engine(
356
+ model_id=model_id,
357
+ draft_id=None,
358
+ kv_bits=kv_bits,
359
+ max_context=max_context,
360
+ cache_dir=cache_dir,
361
+ )
362
+
363
+ # The full-screen TUI loads the 11 GB of weights on a BACKGROUND thread so the banner
364
+ # + input come up in ~0.6 s and you can read/queue while it loads (the load itself is
365
+ # disk-bound and can't be made faster). Headless one-shot and the plain --repl still
366
+ # load synchronously — there's nothing to interact with until the model answers, and
367
+ # a background download prompt would be worse than a blocking one. `--backend openai`
368
+ # only loads a tokenizer (cheap), so it stays synchronous too.
369
+ background = args.backend != "openai" and not task and not args.repl
370
+
371
+ ctx_limit = None
372
+ if not background:
373
+ if args.backend != "openai":
374
+ sys.stderr.write(f"loading {os.path.basename(model_id.rstrip('/'))} [{why}] ...\n")
375
+ try:
376
+ load_s = eng.load()
377
+ except Exception as e: # noqa: BLE001 — convert any load failure into guidance
378
+ _fail_model_load(model_id, e)
379
+ ctx_limit = _compute_ctx_limit(eng)
380
+ sys.stderr.write(f"ready in {load_s:.1f}s | context {eng.effective_ctx} tokens "
381
+ f"(compact at {ctx_limit})\n")
382
+
383
+ start_mode = "plan" if args.plan else ("auto" if args.yolo else "normal")
384
+ thinking = not args.no_think
385
+
386
+ # Resume seeds a FRESH Agent's messages; that Agent mints a new session_id, so the
387
+ # picked/newest session is copied, never overwritten (implicit fork — plan 043).
388
+ # --resume : list recent sessions and pick by number (needs a TTY).
389
+ # -c : the most recent session (unchanged simple case).
390
+ resume = None
391
+ if args.resume:
392
+ from . import session
393
+ items = session.list_sessions(os.getcwd(), limit=10)
394
+ if not items:
395
+ sys.stderr.write("no saved sessions for this directory; starting fresh\n")
396
+ elif not sys.stdin.isatty():
397
+ sys.stderr.write("chad --resume needs an interactive terminal to pick a "
398
+ "session; use -c to resume the most recent one.\n")
399
+ sys.exit(1)
400
+ else:
401
+ pick = _pick_session(items)
402
+ if pick:
403
+ data = session.load_session(os.getcwd(), pick["session_id"])
404
+ if data:
405
+ resume = data["messages"]
406
+ sys.stderr.write(f"resuming (forked): {session.describe(pick)}\n")
407
+ elif args.cont:
408
+ from . import session
409
+ data = session.load_session(os.getcwd())
410
+ if data:
411
+ resume = data["messages"]
412
+ sys.stderr.write(f"resuming session ({session.session_summary(os.getcwd())})\n")
413
+ else:
414
+ sys.stderr.write("no saved session for this directory; starting fresh\n")
415
+
416
+ if task:
417
+ # A one-shot run is inherently unattended: the interactive confirm prompt
418
+ # reads from stdin, which EOFs with no TTY and would abort every edit. So
419
+ # auto-approve mutating tools unless the user asked for read-only --plan.
420
+ run_mode = start_mode
421
+ if run_mode == "normal" and not sys.stdin.isatty():
422
+ run_mode = "auto"
423
+ sys.stderr.write("[headless: auto-approving tools (use --plan for read-only)]\n")
424
+ agent = Agent(eng, yolo=(run_mode == "auto"), ctx_limit=ctx_limit,
425
+ mode=run_mode, thinking=thinking, resume=resume, persist=True,
426
+ think_budget=args.think_budget,
427
+ turn_budget_tokens=args.turn_budget_tokens,
428
+ turn_budget_s=args.turn_budget_s)
429
+ agent.run_turn(task)
430
+ # Plan 040: if the governor hard-stopped the turn on budget, optionally relaunch a
431
+ # FRESH turn (new context + reset KV cache) seeded with the deterministic progress
432
+ # note — shedding both the ramble and the huge prefill the stuck model dragged.
433
+ continues = args.auto_continue
434
+ while agent.budget_note and continues > 0:
435
+ note = agent.budget_note
436
+ continues -= 1
437
+ sys.stderr.write("[governor] previous turn ran out of budget; continuing fresh "
438
+ "with a progress note\n")
439
+ eng.reset()
440
+ agent = Agent(eng, yolo=(run_mode == "auto"), ctx_limit=ctx_limit,
441
+ mode=run_mode, thinking=thinking, persist=True,
442
+ think_budget=args.think_budget,
443
+ turn_budget_tokens=args.turn_budget_tokens,
444
+ turn_budget_s=args.turn_budget_s)
445
+ agent.run_turn(f"{task}\n\n[{note}]")
446
+ agent.save() # persist so a follow-up `chad -c "..."` picks up the thread
447
+ elif args.repl:
448
+ repl(eng, yolo=args.yolo, ctx_limit=ctx_limit, resume=resume, thinking=thinking)
449
+ else:
450
+ from .engine import peek_context_window
451
+ from .tui import run_tui
452
+ _maybe_home_dir_note()
453
+ # Cheap config-only window for the banner + a provisional compaction limit, both
454
+ # shown instantly; `finalize` runs the real load on the TUI's background thread and
455
+ # returns (load_s, ctx_limit) once weights are in.
456
+ window = peek_context_window(model_id, max_context)
457
+ provisional = ctx_limit or _env_int("CHAD_CTX_LIMIT") \
458
+ or min(max(4096, (window or 32768) - 2048), 120_000)
459
+
460
+ def finalize():
461
+ load_s = eng.load()
462
+ return load_s, _compute_ctx_limit(eng)
463
+
464
+ run_tui(eng, provisional, mode=start_mode, thinking=thinking, resume=resume,
465
+ ctx_window=window, finalize=finalize)
466
+
467
+
468
+ if __name__ == "__main__":
469
+ main()
chad/compaction.py ADDED
@@ -0,0 +1,108 @@
1
+ """Context compaction for long agentic sessions (extracted from agent.py).
2
+
3
+ Operates on a plain `messages` list plus two callbacks — `render()` (returns the
4
+ current prompt token ids) and `emit(kind, text)` (status/info display) — so it
5
+ carries no Agent state. `Agent` calls `compact_if_needed(self.messages, self._render,
6
+ self._emit, self.ctx_limit, prompt_ids)`; the `compact_now` (/compact) path reuses
7
+ `_headtail`/`_COLLAPSED` via the Agent aliases. Behavior is byte-identical to the old
8
+ in-class method — same passes, same thresholds (0.7 target, keep-last-N), same order.
9
+ """
10
+
11
+ from . import skills
12
+
13
+ _COLLAPSED = "[…earlier output trimmed to save context…]"
14
+
15
+
16
+ def _headtail(text: str, head: int = 12, tail: int = 8, max_chars: int = 8000) -> str:
17
+ """Keep the first/last few lines of a long output; drop the middle. The
18
+ head and tail carry the most signal (what a command was / how it ended),
19
+ and this preserves far more than a bare stub while still reclaiming space.
20
+ A char cap also clips pathological single-line blobs (minified files etc.)
21
+ that have too few newlines for line-based trimming to help."""
22
+ lines = text.splitlines()
23
+ if len(lines) > head + tail + 3:
24
+ text = "\n".join(
25
+ lines[:head]
26
+ + [f" {_COLLAPSED} ({len(lines) - head - tail} lines)"]
27
+ + lines[-tail:])
28
+ if len(text) > max_chars:
29
+ keep = max_chars // 2
30
+ text = text[:keep] + f"\n {_COLLAPSED}\n" + text[-keep:]
31
+ return text
32
+
33
+
34
+ def compact_if_needed(messages, render, emit, ctx_limit, prompt_ids):
35
+ """Context compaction for long agentic sessions. On a non-trimmable cache
36
+ (Ornith) any prefix change forces a full re-prefill, so compaction is
37
+ expensive — we must reclaim enough in ONE pass that it won't re-trigger next
38
+ step (otherwise: full re-prefill every step). We therefore reclaim down to a
39
+ target well below the limit, escalating until we get there:
40
+ 1. strip stale <think> reasoning from older assistant turns;
41
+ 2. head/tail-truncate the oldest large tool outputs;
42
+ 3. as a last resort, drop the oldest messages entirely.
43
+ Recent context (last few tool results / assistant turns) is kept verbatim."""
44
+ if len(prompt_ids) <= ctx_limit:
45
+ return prompt_ids
46
+ target = int(ctx_limit * 0.7) # reclaim to here so we don't recompact soon
47
+ emit("status", "Compacting context")
48
+ before = len(prompt_ids)
49
+
50
+ def cur_len():
51
+ return len(render())
52
+
53
+ def trunc_tools(keep_recent, head, tail, max_chars):
54
+ # Activated skill instructions are durable behavioral guidance — exempt them
55
+ # from truncation (silently shrinking them degrades the agent with no error).
56
+ idxs = [i for i, m in enumerate(messages)
57
+ if m.get("role") == "tool" and _COLLAPSED not in m["content"]
58
+ and not skills.is_skill_message(m)]
59
+ stop = len(idxs) - keep_recent if keep_recent else len(idxs)
60
+ for i in idxs[:stop]:
61
+ if len(messages[i]["content"]) > 400:
62
+ messages[i]["content"] = _headtail(
63
+ messages[i]["content"], head, tail, max_chars)
64
+
65
+ # pass 1: drop old reasoning (keep the last 2 assistant turns' thinking)
66
+ for i in [i for i, m in enumerate(messages)
67
+ if m.get("role") == "assistant" and "</think>" in m["content"]][:-2]:
68
+ c = messages[i]["content"]
69
+ messages[i]["content"] = c.split("</think>", 1)[1].lstrip("\n")
70
+
71
+ # pass 2: head/tail-truncate older tool outputs (keep the last 4 verbatim)
72
+ if cur_len() > target:
73
+ trunc_tools(keep_recent=4, head=12, tail=8, max_chars=8000)
74
+ # pass 3: drop the oldest messages outright (keep system + recent window) —
75
+ # old context is less useful than recent, so shed it before touching recent.
76
+ # NEVER delete the system prompt or the most recent real user query (the
77
+ # active task) and everything after it: removing the active query makes the
78
+ # chat template raise "No user query found in messages" and crashes the turn.
79
+ def _last_user_idx():
80
+ for i in range(len(messages) - 1, 0, -1):
81
+ if messages[i].get("role") == "user":
82
+ return i
83
+ return None
84
+ BATCH = 8 # delete up to this many oldest messages between budget re-checks
85
+ guard = 0
86
+ while cur_len() > target and guard < 500:
87
+ lu = _last_user_idx()
88
+ ceil = lu if lu is not None else len(messages)
89
+ # Oldest deletable indices: after the system prompt, before the active user query,
90
+ # and never an activated skill message (durable guidance is kept verbatim). Collect
91
+ # up to BATCH of them in one scan so we re-tokenize (cur_len) once per batch instead
92
+ # of once per deletion — render() tokenizes the whole transcript, so per-message
93
+ # re-checks made pass 3 O(N²).
94
+ victims = [i for i in range(1, ceil)
95
+ if not skills.is_skill_message(messages[i])][:BATCH]
96
+ if not victims: # nothing left to shed without touching protected content
97
+ break
98
+ for i in reversed(victims): # delete high→low so earlier indices stay valid
99
+ del messages[i]
100
+ guard += 1
101
+ # pass 4: last resort — recent outputs alone still exceed target; truncate
102
+ # everything that's left, hard.
103
+ if cur_len() > target:
104
+ trunc_tools(keep_recent=0, head=6, tail=4, max_chars=2000)
105
+
106
+ new_ids = render()
107
+ emit("info", f" [compacted context: {before}→{len(new_ids)} tokens]")
108
+ return new_ids
chad/config.py ADDED
@@ -0,0 +1,63 @@
1
+ """Single source of truth for CHAD_* configuration.
2
+
3
+ Typed accessors so every flag parses env the same way, and one place docs/tests can point
4
+ at (the substrate the docs drifted away from — plan 051). The int/float helpers fold in
5
+ the lenient-parse contract plan 052 gave `agent.py`: a non-numeric value warns and degrades
6
+ to the default instead of raising, so a typo in a budget knob can't abort startup.
7
+
8
+ Callers that resolve a value ONCE at import for hot-loop reasons keep doing so
9
+ (`agent._PREFILL_TRACE`, `diag._DISABLED`, `validate.VALIDATE`) — they just call `flag`/
10
+ `env_str` at import instead of hand-rolling `os.environ.get`. Nothing here is read on the
11
+ per-call path unless the caller chooses to; these are thin wrappers, not a live registry.
12
+ """
13
+
14
+ import logging
15
+ import os
16
+
17
+ log = logging.getLogger("chad") # same named logger as diag.log; no chad-module imports
18
+
19
+
20
+ def flag(name: str) -> bool:
21
+ """A CHAD_* boolean: truthy iff the var is set to a non-empty value. Matches the
22
+ repo's dominant `bool(os.environ.get(...))` / `not os.environ.get(...)` convention.
23
+ NOTE: any non-empty value is true, including "0" — use `eq(name, "1")` when a var
24
+ demands a strict `== "1"` opt-in rather than mere presence."""
25
+ return bool(os.environ.get(name))
26
+
27
+
28
+ def eq(name: str, expected: str) -> bool:
29
+ """Strict equality against the raw env value (no strip/case-fold). For the rare var
30
+ whose contract is exactly `== "1"` and would be loosened by `flag`."""
31
+ return os.environ.get(name) == expected
32
+
33
+
34
+ def env_str(name, default=None):
35
+ """The env value if set to a non-empty string, else `default`. Mirrors the common
36
+ `os.environ.get(name) or <fallback>` idiom (empty string collapses to the default)."""
37
+ return os.environ.get(name) or default
38
+
39
+
40
+ def env_int(name, default=None):
41
+ """Parse an int from env var `name`, or fall back to `default`. Unset/empty → default;
42
+ a non-numeric value warns and degrades to the default instead of raising (lenient-parse
43
+ rule, plan 052) so a typo can't abort startup."""
44
+ v = os.environ.get(name)
45
+ if not v:
46
+ return default
47
+ try:
48
+ return int(v)
49
+ except ValueError:
50
+ log.warning("ignoring non-integer %s=%r; using default %r", name, v, default)
51
+ return default
52
+
53
+
54
+ def env_float(name, default=None):
55
+ """float sibling of `env_int` — same lenient-parse contract."""
56
+ v = os.environ.get(name)
57
+ if not v:
58
+ return default
59
+ try:
60
+ return float(v)
61
+ except ValueError:
62
+ log.warning("ignoring non-float %s=%r; using default %r", name, v, default)
63
+ return default