chad-code 2.0.1__tar.gz → 2.0.3__tar.gz

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.
Files changed (108) hide show
  1. {chad_code-2.0.1 → chad_code-2.0.3}/PKG-INFO +4 -4
  2. {chad_code-2.0.1 → chad_code-2.0.3}/README.md +3 -3
  3. {chad_code-2.0.1 → chad_code-2.0.3}/pyproject.toml +3 -1
  4. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/__init__.py +1 -1
  5. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/agent.py +79 -8
  6. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/base_engine.py +6 -3
  7. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/completion_engine.py +7 -3
  8. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/engine.py +100 -38
  9. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/prompt.py +15 -3
  10. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/serve.py +11 -2
  11. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad_code.egg-info/PKG-INFO +4 -4
  12. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad_code.egg-info/SOURCES.txt +3 -1
  13. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_agent.py +67 -3
  14. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_agent_e2e.py +1 -1
  15. chad_code-2.0.3/tests/test_matrix_bench.py +381 -0
  16. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_serve.py +1 -1
  17. chad_code-2.0.3/tests/test_warm_prefix_tiers.py +271 -0
  18. {chad_code-2.0.1 → chad_code-2.0.3}/LICENSE +0 -0
  19. {chad_code-2.0.1 → chad_code-2.0.3}/setup.cfg +0 -0
  20. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/ambient.py +0 -0
  21. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/atif.py +0 -0
  22. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/bench.py +0 -0
  23. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/checkpoint.py +0 -0
  24. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/cli.py +0 -0
  25. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/compaction.py +0 -0
  26. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/config.py +0 -0
  27. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/diag.py +0 -0
  28. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/guardrails.py +0 -0
  29. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/ignore.py +0 -0
  30. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/levers.py +0 -0
  31. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/mcp.py +0 -0
  32. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/mcp_oauth.py +0 -0
  33. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/mlx_dflash.py +0 -0
  34. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/mlx_fastpath.py +0 -0
  35. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/mlx_qmm_mma.py +0 -0
  36. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/mlx_qsdpa.py +0 -0
  37. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/LICENSE +0 -0
  38. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/__init__.py +0 -0
  39. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/alignment.py +0 -0
  40. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/attention.py +0 -0
  41. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/audio.py +0 -0
  42. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/cache.py +0 -0
  43. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/conformer.py +0 -0
  44. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/ctc.py +0 -0
  45. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/parakeet.py +0 -0
  46. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/rnnt.py +0 -0
  47. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/tokenizer.py +0 -0
  48. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/parakeet/utils.py +0 -0
  49. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/prove.py +0 -0
  50. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/render.py +0 -0
  51. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/repomap.py +0 -0
  52. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/seatbelt.py +0 -0
  53. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/session.py +0 -0
  54. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/skills.py +0 -0
  55. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/speech.py +0 -0
  56. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/spill.py +0 -0
  57. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/syntaxgate.py +0 -0
  58. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/toolcall_parse.py +0 -0
  59. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/tools.py +0 -0
  60. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/tui.py +0 -0
  61. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad/validate.py +0 -0
  62. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad_code.egg-info/dependency_links.txt +0 -0
  63. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad_code.egg-info/entry_points.txt +0 -0
  64. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad_code.egg-info/requires.txt +0 -0
  65. {chad_code-2.0.1 → chad_code-2.0.3}/src/chad_code.egg-info/top_level.txt +0 -0
  66. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_agent_guards.py +0 -0
  67. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_ambient.py +0 -0
  68. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_atif.py +0 -0
  69. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_bench.py +0 -0
  70. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_checkpoint.py +0 -0
  71. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_cli.py +0 -0
  72. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_compaction.py +0 -0
  73. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_completion_engine.py +0 -0
  74. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_config.py +0 -0
  75. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_confirm_preview.py +0 -0
  76. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_edit.py +0 -0
  77. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_engine.py +0 -0
  78. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_engine_dflash.py +0 -0
  79. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_engine_kvquant.py +0 -0
  80. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_engine_pld_hybrid.py +0 -0
  81. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_engine_pld_wide.py +0 -0
  82. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_feel_pack.py +0 -0
  83. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_gate.py +0 -0
  84. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_ignore.py +0 -0
  85. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_intent.py +0 -0
  86. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_levers.py +0 -0
  87. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_log_redaction.py +0 -0
  88. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_mcp.py +0 -0
  89. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_mcp_oauth.py +0 -0
  90. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_mlx_fastpath.py +0 -0
  91. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_mlx_qmm_mma.py +0 -0
  92. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_mlx_qsdpa.py +0 -0
  93. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_prove.py +0 -0
  94. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_render.py +0 -0
  95. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_repomap.py +0 -0
  96. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_repomap_polyglot.py +0 -0
  97. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_seatbelt.py +0 -0
  98. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_session.py +0 -0
  99. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_skills.py +0 -0
  100. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_speech.py +0 -0
  101. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_speech_tui.py +0 -0
  102. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_spill.py +0 -0
  103. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_syntaxgate.py +0 -0
  104. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_toolcall_dialect.py +0 -0
  105. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_toolcall_parse.py +0 -0
  106. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_tools.py +0 -0
  107. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_tui.py +0 -0
  108. {chad_code-2.0.1 → chad_code-2.0.3}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Claude-Code-style coding agent that runs entirely on a 24 GB Apple Silicon Mac (MLX, Qwen3.8-27B)
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/nathansutton/chad
@@ -33,11 +33,11 @@ Provides-Extra: speech
33
33
  Requires-Dist: sounddevice>=0.5; extra == "speech"
34
34
  Dynamic: license-file
35
35
 
36
- # chad: a Claude-Code-style coding agent that fits on a 24 GB MacBook
36
+ # chad: a local Claude-Code-style coding agent for your laptop
37
37
 
38
38
  [![tests](https://github.com/nathansutton/chad/actions/workflows/tests.yml/badge.svg)](https://github.com/nathansutton/chad/actions/workflows/tests.yml)
39
39
 
40
- <img src="docs/claude-vs-chad.jpg" width="33%" alt="Two staircase newel posts side by side: Claude is a hand-carved wooden horse head, chad is a scuffed plastic toy horse tied on with twine">
40
+ <img src="docs/claude-vs-chad.jpg" width="66%" alt="Two staircase newel posts side by side: Claude is a hand-carved wooden horse head, chad is a scuffed plastic toy horse tied on with twine">
41
41
 
42
42
  > Claude can do anything, for anyone, anywhere. chad does one thing. 🗿
43
43
  > *Coding under supervision.*
@@ -118,7 +118,7 @@ one checkpoint it ships:
118
118
  cache, so a follow-up step prefills the ~16 tokens it appended instead of the 5,000 it
119
119
  already read: **~0.75 s per step instead of ~50 s**. Any server with prompt caching gets
120
120
  the easy case; the work is holding it true across compaction, truncated turns and restarts.
121
- The system prefix is checkpointed to disk, so the second session in a project starts warm
121
+ The system prefix is checkpointed to disk, so the second session anywhere starts warm
122
122
  (75.6 s → 5.5 s to the first tool call).
123
123
  - **Fused Metal kernels.** Quantized-KV attention, a small-M matmul for speculative verify,
124
124
  and a compiled single-token layer step, chosen per machine at load time, no knobs.
@@ -1,8 +1,8 @@
1
- # chad: a Claude-Code-style coding agent that fits on a 24 GB MacBook
1
+ # chad: a local Claude-Code-style coding agent for your laptop
2
2
 
3
3
  [![tests](https://github.com/nathansutton/chad/actions/workflows/tests.yml/badge.svg)](https://github.com/nathansutton/chad/actions/workflows/tests.yml)
4
4
 
5
- <img src="docs/claude-vs-chad.jpg" width="33%" alt="Two staircase newel posts side by side: Claude is a hand-carved wooden horse head, chad is a scuffed plastic toy horse tied on with twine">
5
+ <img src="docs/claude-vs-chad.jpg" width="66%" alt="Two staircase newel posts side by side: Claude is a hand-carved wooden horse head, chad is a scuffed plastic toy horse tied on with twine">
6
6
 
7
7
  > Claude can do anything, for anyone, anywhere. chad does one thing. 🗿
8
8
  > *Coding under supervision.*
@@ -83,7 +83,7 @@ one checkpoint it ships:
83
83
  cache, so a follow-up step prefills the ~16 tokens it appended instead of the 5,000 it
84
84
  already read: **~0.75 s per step instead of ~50 s**. Any server with prompt caching gets
85
85
  the easy case; the work is holding it true across compaction, truncated turns and restarts.
86
- The system prefix is checkpointed to disk, so the second session in a project starts warm
86
+ The system prefix is checkpointed to disk, so the second session anywhere starts warm
87
87
  (75.6 s → 5.5 s to the first tool call).
88
88
  - **Fused Metal kernels.** Quantized-KV attention, a small-M matmul for speculative verify,
89
89
  and a compiled single-token layer step, chosen per machine at load time, no knobs.
@@ -4,7 +4,7 @@
4
4
  # import name, and command name are independent. `uvx chad-code` runs the alias
5
5
  # script added under [project.scripts].
6
6
  name = "chad-code"
7
- version = "2.0.1"
7
+ version = "2.0.3"
8
8
  description = "Claude-Code-style coding agent that runs entirely on a 24 GB Apple Silicon Mac (MLX, Qwen3.8-27B)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -138,6 +138,8 @@ testpaths = ["tests"]
138
138
 
139
139
  [tool.ruff]
140
140
  line-length = 100
141
+ # Vendored Exercism exercises (benchmarks/matrix/tasks/) are upstream files, kept byte-identical.
142
+ extend-exclude = ["benchmarks/matrix/tasks"]
141
143
  src = ["src", "tests"]
142
144
 
143
145
  [tool.ruff.lint]
@@ -4,7 +4,7 @@ A flat collection of cooperating modules behind one console script (``chad``):
4
4
  the inference engine, the tool layer, the agent loop, and the terminal UI.
5
5
  """
6
6
 
7
- __version__ = "2.0.1"
7
+ __version__ = "2.0.3"
8
8
 
9
9
  # chad sets no MLX_* runtime vars. MLX_METAL_FAST_SYNCH, MLX_MAX_OPS_PER_BUFFER
10
10
  # and MLX_MAX_MB_PER_BUFFER were each measured end-to-end on the 35B and every
@@ -29,7 +29,7 @@ from . import (
29
29
  )
30
30
  from .base_engine import BaseEngine
31
31
  from .diag import args_preview, log, redact, result_preview
32
- from .prompt import build_system_prompt, classify_intent
32
+ from .prompt import build_system_prompt, classify_intent, static_system_prompt
33
33
  from .render import (
34
34
  C_DIM,
35
35
  C_RED,
@@ -206,8 +206,15 @@ def close_unclosed_think(text: str, thinking: bool) -> str:
206
206
  re-prefill of the whole transcript next step (measured: tens of thousands of tokens
207
207
  at large context). Appending the missing `</think>` keeps the cached tokens a strict
208
208
  prefix of the re-render, so only a couple of tokens prefill instead. No-op when
209
- thinking is off or the block is already closed."""
210
- if thinking and "<think>" not in text and "</think>" not in text and text:
209
+ thinking is off or the block is already closed.
210
+
211
+ Only the CLOSE decides this. An earlier guard also required no `<think>` in the
212
+ text, which read a stray opening tag — one the model wrote inside its own reasoning,
213
+ not the template's — as "already a block" and skipped the append. That turn then
214
+ reached `split_inline_reasoning` with no `</think>` to split on, so it was stored raw
215
+ and re-rendered with an injected empty think block: the exact full re-prefill this
216
+ function exists to prevent, on the longest turns, where it costs the most."""
217
+ if thinking and "</think>" not in text and text:
211
218
  return text + "\n</think>"
212
219
  return text
213
220
 
@@ -237,8 +244,17 @@ def split_inline_reasoning(m: dict) -> dict:
237
244
  if m.get("role") != "assistant" or "</think>" not in content:
238
245
  return m
239
246
  head, _, tail = content.partition("</think>")
247
+ head = head.rstrip("\n")
248
+ # Strip a LEADING `<think>` only. The generation prompt already opened the block, so
249
+ # any later tag is reasoning the model wrote, not a delimiter. Taking the LAST one
250
+ # (`split("<think>")[-1]`) silently dropped every reasoning token before a stray tag:
251
+ # transcript content lost, and the shortened re-render diverges from the cache right
252
+ # where the turn began.
253
+ unwrapped = head.lstrip()
254
+ if unwrapped.startswith("<think>"):
255
+ head = unwrapped[len("<think>"):]
240
256
  return {**m,
241
- "reasoning_content": head.rstrip("\n").split("<think>")[-1].lstrip("\n"),
257
+ "reasoning_content": head.lstrip("\n"),
242
258
  "content": tail.lstrip("\n")}
243
259
 
244
260
 
@@ -788,6 +804,36 @@ class Agent:
788
804
  n += 1
789
805
  return list(a[:n])
790
806
 
807
+ def _static_head_ids(self) -> list:
808
+ """The project-independent head of `_stable_prefix_ids`: everything the
809
+ template renders before the per-session tail of the system prompt (cwd,
810
+ workspace listing, project docs). Found the same way — two renders differenced,
811
+ one with the full system prompt and one with only its static text — so it is
812
+ exact under any template, wherever that template puts the tool schemas. The
813
+ engine checkpoints this ONCE for every project and the full prefix once per
814
+ project (`Engine.warm_prefix`). Empty when the system prompt is not chad's own
815
+ or has no per-session tail, in which case only the full prefix is used."""
816
+ sysm = self.messages[0]
817
+ static = static_system_prompt()
818
+ content = sysm.get("content") or ""
819
+ if not content.startswith(static) or content == static:
820
+ return []
821
+ schemas = self._active_schemas()
822
+ def render1(m):
823
+ return self._template_ids(self.engine.tok.apply_chat_template(
824
+ [m, {"role": "user", "content": "a"}], tools=schemas,
825
+ add_generation_prompt=True, enable_thinking=self.thinking))
826
+ a, b = render1({"role": "system", "content": static}), render1(sysm)
827
+ n = 0
828
+ for x, y in zip(a, b):
829
+ if x != y:
830
+ break
831
+ n += 1
832
+ head, full = list(b[:n]), self._stable_prefix_ids()
833
+ if len(head) >= len(full) or full[: len(head)] != head:
834
+ return []
835
+ return head
836
+
791
837
  def _confirm(self, name, args) -> bool:
792
838
  # Destructive-bash seatbelt: a catastrophic shell command (rm -rf ~, mkfs,
793
839
  # curl|sh, …) is screened even in --yolo mode, because the model acts on
@@ -861,14 +907,39 @@ class Agent:
861
907
  pass
862
908
  # Warm start: on a cold cache, load the system+tools KV from disk
863
909
  # (or prefill+persist it once) so the first turn doesn't re-prefill the
864
- # ~3.2k-token stable prefix every session. Cheap no-op on a warm cache.
910
+ # ~2.5k-token stable prefix every session. Two tiers: the full prefix (same
911
+ # project) and its static head (any project — only the per-project tail is
912
+ # prefilled). Cheap no-op on a warm cache.
865
913
  if self.engine.cache_dir and not self.engine._cached_ids:
866
914
  try:
867
- status, n = self.engine.warm_prefix(self._stable_prefix_ids(),
868
- should_stop=self._should_stop)
869
- log.info("CACHE warm-start %s: %d prefix tokens (disk KV cache)", status, n)
915
+ t_warm = time.time()
916
+ full = self._stable_prefix_ids()
917
+ status, n = self.engine.warm_prefix(full, should_stop=self._should_stop,
918
+ head_ids=self._static_head_ids())
919
+ warm_s = time.time() - t_warm
920
+ log.info("CACHE warm-start %s: %d prefix tokens (disk KV cache, %.1fs)",
921
+ status, n, warm_s)
870
922
  if status == "hit":
871
923
  self._emit("info", f" [warm start: {n:,} prefix tokens from disk cache]")
924
+ elif status == "partial":
925
+ self._emit("info", f" [warm start: {n:,} prefix tokens from disk cache; "
926
+ f"{len(full) - n:,} project tokens prefilled "
927
+ f"in {warm_s:.1f}s]")
928
+ # The prefix prefill happens BEFORE step 1, so a per-step trace would
929
+ # never see it: a miss is the whole cold prefill of the system prompt,
930
+ # paid while the user is already waiting. Recorded as its own row (seq 0)
931
+ # so an offline reader can add it to the first step's wait; step rows
932
+ # are untouched (their cached_tokens already count the prefix).
933
+ if _PREFILL_TRACE and status != "skip":
934
+ # prefix_tokens is what `status` refers to (hit/partial: restored,
935
+ # miss: prefilled); prefilled_tokens is the cold part either way.
936
+ _trace_prefill({"seq": 0, "step": -1, "kind": "warm_prefix",
937
+ "status": status, "prefix_tokens": n,
938
+ "total_prefix": len(full),
939
+ "prefilled_tokens": (0 if status == "hit"
940
+ else len(full) - n
941
+ if status == "partial" else n),
942
+ "prefill_s": round(warm_s, 4)})
872
943
  except Exception as e: # never let cache warming break a turn
873
944
  log.warning("warm_prefix failed: %s", e)
874
945
  # @file mentions: inline any referenced files so the model has them without a
@@ -207,10 +207,13 @@ class BaseEngine(Protocol):
207
207
  `_reset_cache`). Called on `/reset` and by the governor's fresh-turn relaunch."""
208
208
  ...
209
209
 
210
- def warm_prefix(self, prefix_ids: list, should_stop: Optional[Callable[[], bool]] = None
211
- ) -> tuple[str, int]:
210
+ def warm_prefix(self, prefix_ids: list, should_stop: Optional[Callable[[], bool]] = None,
211
+ head_ids: Optional[list] = None) -> tuple[str, int]:
212
212
  """Warm-start the stable system+tools prefix from disk (MLX) or no-op ('skip', 0)
213
- on a stateless backend. Returns (status, n_tokens)."""
213
+ on a stateless backend. `head_ids`, if given, is the project-independent head
214
+ of `prefix_ids` (a proper prefix of it), checkpointed once for every project so
215
+ a fresh directory restores it and prefills only the tail. Returns
216
+ (status, n_tokens) with status 'hit' | 'partial' | 'miss' | 'skip'."""
214
217
  ...
215
218
 
216
219
  def push_cache(self) -> None:
@@ -236,7 +236,8 @@ class CompletionEngine:
236
236
  _reset_cache = reset # cli/bench call the private spelling; keep it working
237
237
 
238
238
  def warm_prefix(self, prefix_ids: list,
239
- should_stop: Optional[Callable[[], bool]] = None) -> tuple[str, int]:
239
+ should_stop: Optional[Callable[[], bool]] = None,
240
+ head_ids: Optional[list] = None) -> tuple[str, int]:
240
241
  """Warm-start the stable prefix. Against stock llama.cpp there is nothing to do
241
242
  — the KV checkpoint would live on the SERVER's disk, which we can't reach — so
242
243
  this stays 'skip'. A `chad serve` server advertising `warm_prefix` does the
@@ -246,12 +247,15 @@ class CompletionEngine:
246
247
  self._ensure_caps()
247
248
  if CAP_WARM_PREFIX not in self._caps:
248
249
  return ("skip", 0)
249
- resp = self._post_json("/warm", {"prefix": list(prefix_ids)})
250
+ body = {"prefix": list(prefix_ids)}
251
+ if head_ids:
252
+ body["head"] = list(head_ids)
253
+ resp = self._post_json("/warm", body)
250
254
  if not resp:
251
255
  return ("skip", 0)
252
256
  status = str(resp.get("status") or "skip")
253
257
  fed = int(resp.get("fed") or 0)
254
- if status in ("hit", "miss") and fed:
258
+ if status in ("hit", "miss", "partial") and fed:
255
259
  # Mirror what the server now holds, so the first turn's prefill estimate
256
260
  # accounts for the prefix we just warmed instead of assuming a cold cache.
257
261
  self._cached_ids = list(prefix_ids[:fed])
@@ -550,6 +550,9 @@ class Engine:
550
550
  _pld_hybrid: bool = field(init=False, default=False)
551
551
  _model_path: str = field(init=False, default="") # resolved weights dir
552
552
  _warm_prefix_ids: Any = field(init=False, default=None)
553
+ # The project-independent head of that prefix (tool schemas + behavioral prompt),
554
+ # checkpointed once for every working directory; see warm_prefix.
555
+ _warm_head_ids: Any = field(init=False, default=None)
553
556
  kv_bytes_per_token: float = field(init=False, default=0.0) # measured at load (036)
554
557
  # Model-shape facts read from config at load, for the adaptive prefill chunk.
555
558
  # MoE prefill amortizes routing with bigger chunks (+14% measured 512→2048 on
@@ -934,43 +937,92 @@ class Engine:
934
937
  kind = _CKPT_PUSH if tag == _CKPT_PUSH else _CKPT_WARM
935
938
  return os.path.join(self.cache_dir, f"{kind}-{h.hexdigest()}.safetensors") # type: ignore[arg-type]
936
939
 
937
- def warm_prefix(self, prefix_ids: list, should_stop=None):
938
- """Make a cold session start warm. If a disk checkpoint for exactly these
939
- prefix tokens exists, load it into the live cache (ZERO prefill); otherwise
940
- prefill the prefix once and persist it for next time. Only valid on a cold
941
- cache. Returns (status, n_tokens) where status is 'hit' | 'miss' | 'skip'."""
940
+ def warm_prefix(self, prefix_ids: list, should_stop=None, head_ids=None):
941
+ """Make a cold session start warm. Two checkpoints can serve it, longest first:
942
+
943
+ - the FULL prefix (tool schemas + system prompt, per-project tail included):
944
+ a hit is a restart in the same project — ZERO prefill;
945
+ - the static HEAD (`head_ids`: the part of the prefix that is byte-identical in
946
+ every project — tool schemas + behavioral prompt): a hit restores the head
947
+ and prefills only the per-project tail (cwd, workspace listing, project
948
+ docs — a few hundred tokens), so a fresh directory no longer pays the whole
949
+ ~2.5k-token cold prefill. Before this tier existed, the checkpoint was
950
+ keyed on the whole prompt and a new directory could never hit it.
951
+
952
+ A miss prefills the prefix once and persists BOTH checkpoints for next time.
953
+ Only valid on a cold cache. Returns (status, n_tokens): 'hit' (n = tokens
954
+ restored), 'partial' (head restored, n = its tokens; the rest was prefilled),
955
+ 'miss' (n = tokens prefilled), 'skip'."""
942
956
  if not self.cache_dir or not prefix_ids:
943
957
  return ("skip", 0)
944
958
  if self._cached_ids: # cache already populated this session
945
959
  return ("skip", 0)
960
+ prefix_ids = list(prefix_ids)
946
961
  path = self._ckpt_path(prefix_ids)
947
- if os.path.isfile(path):
948
- try:
949
- loaded = cache_utils.load_prompt_cache(path)
950
- # sanity: a loaded cache must have one entry per model layer
951
- if len(loaded) == self._n_model_layers():
952
- self._cache = loaded
953
- self._cached_ids = list(prefix_ids)
954
- self._warm_prefix_ids = list(prefix_ids)
955
- self._set_cache_flags()
956
- return ("hit", len(prefix_ids))
957
- except Exception:
958
- pass # corrupt/incompatible -> recompute below
959
- # miss: prefill the prefix into a fresh cache, then persist it.
962
+ if os.path.isfile(path) and self._load_ckpt(path, prefix_ids):
963
+ self._warm_prefix_ids = list(prefix_ids)
964
+ return ("hit", len(prefix_ids))
965
+ head = list(head_ids or [])
966
+ if not (head and len(head) < len(prefix_ids) and prefix_ids[: len(head)] == head):
967
+ head = [] # not a proper prefix of this prefix: unusable
968
+ tail = prefix_ids[len(head):]
969
+ if head:
970
+ self._warm_head_ids = list(head)
971
+ hp = self._ckpt_path(head)
972
+ if os.path.isfile(hp) and self._load_ckpt(hp, head):
973
+ fed = self._prefill(tail, should_stop)
974
+ if fed < len(tail): # interrupted -> don't persist a partial
975
+ self._cached_ids = prefix_ids[: len(head) + fed]
976
+ return ("partial", len(head))
977
+ self._cached_ids = list(prefix_ids)
978
+ self._warm_prefix_ids = list(prefix_ids)
979
+ self._save_ckpt(path)
980
+ return ("partial", len(head))
981
+ # miss: prefill into a fresh cache and persist — the head first (so the NEXT
982
+ # fresh directory gets a partial hit), then the whole prefix.
960
983
  self._reset_cache()
961
- fed = self._prefill(list(prefix_ids), should_stop)
962
- if fed < len(prefix_ids): # interrupted -> don't persist a partial
963
- self._cached_ids = list(prefix_ids[:fed])
964
- return ("miss", fed)
984
+ if head:
985
+ fed = self._prefill(head, should_stop)
986
+ if fed < len(head):
987
+ self._cached_ids = head[:fed]
988
+ return ("miss", fed)
989
+ self._cached_ids = list(head)
990
+ self._save_ckpt(hp)
991
+ fed = self._prefill(tail, should_stop)
992
+ if fed < len(tail):
993
+ self._cached_ids = prefix_ids[: len(head) + fed]
994
+ return ("miss", len(head) + fed)
965
995
  self._cached_ids = list(prefix_ids)
966
996
  self._warm_prefix_ids = list(prefix_ids)
997
+ self._save_ckpt(path)
998
+ return ("miss", len(prefix_ids))
999
+
1000
+ def _load_ckpt(self, path: str, ids: list) -> bool:
1001
+ """Install a KV checkpoint as the live cache for exactly `ids`. False on
1002
+ anything short of a clean load (unreadable, wrong layer count), leaving the
1003
+ caller to recompute."""
1004
+ try:
1005
+ loaded = cache_utils.load_prompt_cache(path)
1006
+ except Exception:
1007
+ return False # corrupt/incompatible -> recompute
1008
+ if len(loaded) != self._n_model_layers():
1009
+ return False
1010
+ self._cache = loaded
1011
+ self._cached_ids = list(ids)
1012
+ self._set_cache_flags()
1013
+ return True
1014
+
1015
+ def _save_ckpt(self, path: str) -> None:
1016
+ """Persist the live cache. Best-effort: a full or read-only disk skips the
1017
+ checkpoint, never the turn."""
1018
+ if not self.cache_dir:
1019
+ return
967
1020
  try:
968
1021
  os.makedirs(self.cache_dir, exist_ok=True)
969
1022
  cache_utils.save_prompt_cache(path, self._cache)
970
1023
  self._enforce_kv_budget(path)
971
1024
  except Exception:
972
- pass # disk full / read-only -> just skip persist
973
- return ("miss", len(prefix_ids))
1025
+ pass
974
1026
 
975
1027
  def _n_model_layers(self) -> int:
976
1028
  return len(self.model.layers)
@@ -1094,25 +1146,32 @@ class Engine:
1094
1146
  def _reload_warm_prefix(self, target_ids: list) -> int:
1095
1147
  """If a disk checkpoint of the warm system prefix exists and target_ids still
1096
1148
  begins with it, load it into the freshly-reset cache and return its length
1097
- (tokens we skip re-prefilling). Returns 0 if unavailable/inapplicable."""
1149
+ (tokens we skip re-prefilling). Falls back to the static head checkpoint when
1150
+ the full one is gone (evicted) or unreadable. Returns 0 if neither applies."""
1098
1151
  wp = self._warm_prefix_ids
1099
- if not (wp and self.cache_dir and len(target_ids) >= len(wp)
1152
+ if (wp and self.cache_dir and len(target_ids) >= len(wp)
1100
1153
  and target_ids[: len(wp)] == wp):
1154
+ path = self._ckpt_path(wp)
1155
+ if os.path.isfile(path):
1156
+ if self._load_ckpt(path, wp):
1157
+ return len(wp)
1158
+ self._reset_cache() # corrupt/incompatible -> clean rebuild
1159
+ return self._reload_warm_head(target_ids)
1160
+
1161
+ def _reload_warm_head(self, target_ids: list) -> int:
1162
+ """Second tier of `_reload_warm_prefix`: the project-independent head (tool
1163
+ schemas + behavioral prompt), when target_ids still begins with it."""
1164
+ hd = getattr(self, "_warm_head_ids", None)
1165
+ if not (hd and self.cache_dir and len(target_ids) >= len(hd)
1166
+ and target_ids[: len(hd)] == hd):
1101
1167
  return 0
1102
- path = self._ckpt_path(wp)
1168
+ path = self._ckpt_path(hd)
1103
1169
  if not os.path.isfile(path):
1104
1170
  return 0
1105
- try:
1106
- loaded = cache_utils.load_prompt_cache(path)
1107
- if len(loaded) != self._n_model_layers():
1108
- return 0
1109
- self._cache = loaded
1110
- self._cached_ids = list(wp)
1111
- self._set_cache_flags()
1112
- return len(wp)
1113
- except Exception:
1114
- self._reset_cache() # corrupt/incompatible -> clean rebuild
1115
- return 0
1171
+ if self._load_ckpt(path, hd):
1172
+ return len(hd)
1173
+ self._reset_cache()
1174
+ return 0
1116
1175
 
1117
1176
  # -- one-deep cache quarantine -----------------------------
1118
1177
  # A subagent explores in a SEPARATE small context so the main transcript's warm
@@ -1228,6 +1287,9 @@ class Engine:
1228
1287
  protect = {just_written}
1229
1288
  if self._warm_prefix_ids:
1230
1289
  protect.add(self._ckpt_path(self._warm_prefix_ids))
1290
+ hd = getattr(self, "_warm_head_ids", None)
1291
+ if hd:
1292
+ protect.add(self._ckpt_path(hd))
1231
1293
  enforce_cache_budget(self.cache_dir, self.kv_cache_max_bytes, protect)
1232
1294
 
1233
1295
  # -- generation -------------------------------------------------------
@@ -217,12 +217,24 @@ def _dynamic_context() -> list:
217
217
  return dynamic
218
218
 
219
219
 
220
+ def static_system_prompt() -> str:
221
+ """The part of the system prompt that is byte-identical in every project and every
222
+ session: the behavioral prompt and the verify-first block. What the chat template
223
+ renders before it (the tool schemas) is static too, so the rendered prefix up to the
224
+ end of this text is the same token sequence in every working directory — which is
225
+ what lets the engine checkpoint it ONCE, globally, instead of once per directory
226
+ (`Agent._static_head_ids`, `Engine.warm_prefix`)."""
227
+ return _BASE_PROMPT + _verify_baseline_block()
228
+
229
+
220
230
  def build_system_prompt() -> str:
221
231
  # Cache-boundary trick (from the Claude Code teardown): everything above the
222
232
  # boundary is static behavioral text that stays identical across sessions, so the
223
- # prefix KV cache reuses it. Volatile per-session context (cwd, project docs) goes
224
- # below, where re-prefilling a few hundred tokens is cheap.
225
- return (_BASE_PROMPT + _verify_baseline_block() + "\n".join(_dynamic_context()))
233
+ # prefix KV cache reuses it. Volatile per-session context (cwd, workspace listing,
234
+ # project docs) goes below, where re-prefilling a few hundred tokens is cheap. The
235
+ # boundary is load-bearing for the on-disk warm start: the static head above it is
236
+ # checkpointed once for every project, the full prompt once per project.
237
+ return static_system_prompt() + "\n".join(_dynamic_context())
226
238
 
227
239
 
228
240
  def _verify_baseline_block() -> str:
@@ -37,7 +37,9 @@ forfeits come back:
37
37
  return trip re-prefills it. Here the sub-agent's excursion is bracketed by a real
38
38
  ``Engine.push_cache``/``pop_cache``, so the main transcript's KV survives it.
39
39
  - ``POST /warm`` — the on-disk KV warm-start of the stable system+tools prefix, which
40
- a remote client cannot do because the checkpoint lives on the *server's* disk.
40
+ a remote client cannot do because the checkpoint lives on the *server's* disk. An
41
+ optional ``head`` (the project-independent part of ``prefix``) lets a client in a
42
+ fresh directory restore that head and prefill only its per-project tail.
41
43
 
42
44
  Both are latency, never correctness: a client that doesn't speak them, or a call that
43
45
  fails, degrades to the plain remote behavior.
@@ -704,6 +706,12 @@ def _make_handler(state: ServerState) -> type:
704
706
  if not isinstance(prefix, list) or not all(
705
707
  isinstance(t, int) and not isinstance(t, bool) for t in prefix):
706
708
  return self._error(400, "prefix must be an array of token ids")
709
+ # Optional second tier: the project-independent head of `prefix`, so a
710
+ # client in a fresh directory restores the head and prefills only its tail.
711
+ head = body.get("head")
712
+ if head is not None and (not isinstance(head, list) or not all(
713
+ isinstance(t, int) and not isinstance(t, bool) for t in head)):
714
+ return self._error(400, "head must be an array of token ids")
707
715
  # Same wall as /completion: warming a prefix that cannot fit is a prefill
708
716
  # that cannot fit, and it reaches the allocator the same way.
709
717
  _, too_big = admit(len(prefix), 0, state.n_ctx(), state.safe_ctx())
@@ -711,7 +719,8 @@ def _make_handler(state: ServerState) -> type:
711
719
  return self._error(400, too_big)
712
720
  state.busy = True
713
721
  try:
714
- status, fed = state.call(state.eng.warm_prefix, list(prefix))
722
+ status, fed = state.call(state.eng.warm_prefix, list(prefix),
723
+ head_ids=list(head) if head else None)
715
724
  except Exception as e: # noqa: BLE001 — same: degrade, don't fail a run
716
725
  return self._send_json(200, {"status": "error", "fed": 0,
717
726
  "error": str(e)})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: Claude-Code-style coding agent that runs entirely on a 24 GB Apple Silicon Mac (MLX, Qwen3.8-27B)
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/nathansutton/chad
@@ -33,11 +33,11 @@ Provides-Extra: speech
33
33
  Requires-Dist: sounddevice>=0.5; extra == "speech"
34
34
  Dynamic: license-file
35
35
 
36
- # chad: a Claude-Code-style coding agent that fits on a 24 GB MacBook
36
+ # chad: a local Claude-Code-style coding agent for your laptop
37
37
 
38
38
  [![tests](https://github.com/nathansutton/chad/actions/workflows/tests.yml/badge.svg)](https://github.com/nathansutton/chad/actions/workflows/tests.yml)
39
39
 
40
- <img src="docs/claude-vs-chad.jpg" width="33%" alt="Two staircase newel posts side by side: Claude is a hand-carved wooden horse head, chad is a scuffed plastic toy horse tied on with twine">
40
+ <img src="docs/claude-vs-chad.jpg" width="66%" alt="Two staircase newel posts side by side: Claude is a hand-carved wooden horse head, chad is a scuffed plastic toy horse tied on with twine">
41
41
 
42
42
  > Claude can do anything, for anyone, anywhere. chad does one thing. 🗿
43
43
  > *Coding under supervision.*
@@ -118,7 +118,7 @@ one checkpoint it ships:
118
118
  cache, so a follow-up step prefills the ~16 tokens it appended instead of the 5,000 it
119
119
  already read: **~0.75 s per step instead of ~50 s**. Any server with prompt caching gets
120
120
  the easy case; the work is holding it true across compaction, truncated turns and restarts.
121
- The system prefix is checkpointed to disk, so the second session in a project starts warm
121
+ The system prefix is checkpointed to disk, so the second session anywhere starts warm
122
122
  (75.6 s → 5.5 s to the first tool call).
123
123
  - **Fused Metal kernels.** Quantized-KV attention, a small-M matmul for speculative verify,
124
124
  and a compiled single-token layer step, chosen per machine at load time, no knobs.
@@ -80,6 +80,7 @@ tests/test_ignore.py
80
80
  tests/test_intent.py
81
81
  tests/test_levers.py
82
82
  tests/test_log_redaction.py
83
+ tests/test_matrix_bench.py
83
84
  tests/test_mcp.py
84
85
  tests/test_mcp_oauth.py
85
86
  tests/test_mlx_fastpath.py
@@ -101,4 +102,5 @@ tests/test_toolcall_dialect.py
101
102
  tests/test_toolcall_parse.py
102
103
  tests/test_tools.py
103
104
  tests/test_tui.py
104
- tests/test_validate.py
105
+ tests/test_validate.py
106
+ tests/test_warm_prefix_tiers.py