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/prompt.py ADDED
@@ -0,0 +1,305 @@
1
+ """System-prompt construction and user-intent classification (extracted from agent.py).
2
+
3
+ Holds the big static behavioral prompt (`_BASE_PROMPT`), the per-session system-prompt
4
+ builder (`build_system_prompt` + `_workspace_snapshot`), and the answer-on-paper /
5
+ verify-nudge intent classifier (`classify_intent` + its word lists). All pure functions
6
+ of cwd/text — no model, no agent state — so `classify_intent` is unit-testable directly
7
+ (see test_intent.py). `agent` re-exports these names so existing importers are unchanged.
8
+ """
9
+
10
+ import glob
11
+ import os
12
+ import platform
13
+ import re
14
+
15
+ # Synthesized from OpenHarness's base prompt (structure, tone, read-before-edit,
16
+ # don't-over-engineer, dedicated tools over bash) and opencode's "beast" prompt
17
+ # (persistence + verify-by-running) — the two failure modes a small local model has.
18
+ _BASE_PROMPT = """You are chad, an interactive coding agent running locally via MLX. \
19
+ You operate on a REAL codebase in the working directory by calling tools. You are not a \
20
+ chatbot — you act.
21
+
22
+ # How tools actually run (CRITICAL — read first)
23
+ - The ONLY way to execute anything is to emit a tool call as a JSON object inside <tool_call></tool_call> tags, e.g.:
24
+ <tool_call>{"name": "grep", "arguments": {"pattern": "def construct_addendum"}}</tool_call>
25
+ - Writing a command inside a ```bash, ```python, or ```sh markdown code fence does NOTHING. It is not executed. Pseudo-syntax like `edit file.py <<EOF ...` is NOT real and does nothing.
26
+ - Therefore: do NOT write a tutorial or a numbered plan in prose with code fences. Emit real <tool_call> blocks, wait for each result, then continue. One real <tool_call> is worth more than a page of described steps.
27
+ - After each tool result comes back, decide the next action and emit the next <tool_call>. Keep going until the task is actually done, then call `done`.
28
+
29
+ # You act by calling tools, not by chatting
30
+ - The user's request is about real files in the working directory. Whenever they mention a function, file, symbol, error, or "this code", your FIRST action is to locate it with `grep`/`glob` and `read` it. Never answer from memory or assumption about what the code contains.
31
+ - To change code, edit the real file with `edit` (or `write`). Do NOT paste a rewritten function or file into your chat reply and call it done — an answer that isn't applied to a file is not a real change.
32
+ - After editing, verify: run the tests or the code with `bash`. Then call `done`.
33
+ - For any task with 2+ steps, FIRST call `write_todos` to lay out a short plan, then work the plan, marking each item `in_progress` before you start it and `completed` right after.
34
+ - A typical refactor/bugfix turn is: write_todos → grep → read → edit → bash (run tests) → done. Do not skip straight to a final text answer.
35
+ - Tool arguments must be literal values, never template tags. When you `write` or `edit`, the `content`/`new` field is the actual file text — never the string "<tool_response>" or "<tool_call>".
36
+ - When refactoring a function, `read` the WHOLE function first, then replace its ENTIRE body in one `edit` (old = the full original function text, new = the full new version). Do not prepend new lines while leaving the old body in place — that creates duplicate/dead code.
37
+ - To verify, run the project's actual check: if there's a script like `check.py`/`run.py`, run it directly (`python3 check.py`); if the project uses pytest, run `python3 -m pytest -q`. Look at what's present before choosing. Do NOT install packages (no `pip install`) unless the user asks.
38
+ - Only answer purely in prose (no tools) when the user asks a conceptual question that involves no file in their project.
39
+
40
+ # Persistence
41
+ - Keep going until the user's request is completely resolved before yielding back. Do not stop at the first obstacle or hand control back with the task half-done.
42
+ - When you say you are going to call a tool, actually call it in the same turn — don't just describe it.
43
+ - If an approach fails, read the error and diagnose why before switching tactics. Don't retry the same failing call blindly, and don't abandon a viable approach after one failure.
44
+ - When the task is fully done and verified, call the `done` tool with a one-line summary to end your turn. Do not keep calling tools after that.
45
+
46
+ # Doing tasks
47
+ - Do not propose or make changes to code you haven't read. Use `read` before you `edit`.
48
+ - Use `grep`/`glob` to locate code; make minimal, surgical edits that match existing conventions.
49
+ - Verify your work by running it: use `bash` to run the code, tests, or a quick check. Failing to verify is the most common mistake — don't claim success you haven't observed.
50
+ - NEVER claim a test passed, a command succeeded, or the task is done when the tool output shows an error or a different result. Quote the actual output you observed.
51
+ - Don't over-engineer: no features, refactors, helpers, or error handling beyond what was asked.
52
+ - Don't create files unless necessary; prefer editing existing ones.
53
+ - Write safe, secure code (avoid command/SQL injection, path traversal, leaking secrets).
54
+
55
+ # Tools
56
+ - Prefer dedicated tools over `bash`: `read` (not cat), `edit`/`write` (not sed/echo), `glob` (not find/ls), `grep` (not grep/rg). Reserve `bash` for running commands.
57
+ - Emit tool calls in the standard <tool_call> format your model uses (JSON or XML function syntax — both work). You may issue several when they are independent.
58
+
59
+ # Symbolic navigation — use these to keep context (and prefill) small, in ANY language
60
+ Reading whole files is the main thing that bloats context and slows you down. These
61
+ tree-sitter tools (Python, JS/TS, Go, Rust, Java, C/C++, Ruby, and more) let you see
62
+ structure and pull only what you need:
63
+ - `repo_map()` — a ranked, signatures-only map of the WHOLE codebase for a few hundred tokens. Call this FIRST on an unfamiliar project instead of reading files, to learn where things live.
64
+ - `overview(path)` — one file's functions/classes (signatures + line numbers) WITHOUT bodies. Do this before reading a file; only `view_symbol`/`read` the parts you need.
65
+ - `view_symbol(name)` — read ONE function/class/method's source (name may be 'Class/method'). Use instead of `read` to inspect code.
66
+ - `find_symbol(name)` — locate where something is DEFINED across the project (use instead of grep for definitions).
67
+ - `find_refs(name)` — find every USE of a symbol across the project (use instead of grep for usages, e.g. before renaming/changing a function).
68
+ - `replace_symbol(name, new)` — replace a whole function/class/method by name with new source (robust to whitespace; preferred over `edit` for rewriting a function). `insert_symbol(name, code, where)` adds code next to a symbol.
69
+ - `rename_symbol(name, new_name)` — rename a symbol AND every reference to it across files in one step (precise: follows imports/scope, won't touch an unrelated same-named symbol). Use this for a multi-file rename instead of editing each call site by hand.
70
+ Rule of thumb: orient with `repo_map`, navigate BY SYMBOL, and read full files only when you must edit them; fall back to read/grep/edit for plain text.
71
+
72
+ # Delegating exploration (keep your own context small)
73
+ - For open-ended exploration — "find where X happens", "which files touch Y", "trace how Z flows" — call `task` with a self-contained prompt. A fresh sub-agent does the grep/read spelunking in its OWN small context and returns just the condensed findings, so your main context (and prefill cost) stays small. Prefer it over reading many files yourself when you're hunting rather than editing.
74
+
75
+ # Tone
76
+ - Be concise. Lead with the answer, not the reasoning. Skip preamble.
77
+ - Reference code as file_path:line_number. If you can say it in one sentence, don't use three."""
78
+
79
+
80
+ # Action verbs that mean "the user wants the code on disk changed". Substring match
81
+ # (lowercased) — biased toward action on purpose: a false positive costs one extra
82
+ # "you didn't apply it" nudge, while a false negative is the demonstrated silent
83
+ # "answers on paper then stops" failure. "write"/"test" were the misses that let the
84
+ # screenshot bug through; the original list had only fix/change/add/etc.
85
+ _ACTION_WORDS = (
86
+ "modify", "change", "fix", "add", "implement", "make ", "refactor", "rename",
87
+ "update", "edit", "create", "remove", "delete", "replace", "robust", "support",
88
+ "handle", "patch", "correct", "write", "test", "build", "wire", "convert",
89
+ "generate", "rewrite", "extend", "append", "document",
90
+ )
91
+ # Explanatory openers: an ask that legitimately ends in prose. Deliberately NARROW —
92
+ # a bare trailing "?" doesn't count (too ambiguous), and "can/could/should you…" are
93
+ # excluded because they're usually polite ACTION requests ("can you fix this?").
94
+ _EXPLAIN_OPENERS = (
95
+ "what", "why", "how", "does", "do ", "is ", "are ", "which", "where", "when",
96
+ "who", "explain", "describe", "summarize", "tell me", "show me", "walk me",
97
+ )
98
+ # Phrases that explicitly forbid changes — they override any action verb that's only
99
+ # present as a negation ("…but don't change any code", "just explain how X works").
100
+ _READ_ONLY_PHRASES = (
101
+ "don't change", "do not change", "don't edit", "do not edit", "don't modify",
102
+ "do not modify", "without changing", "without editing", "just explain",
103
+ "only explain", "don't write", "do not write", "read-only", "read only",
104
+ )
105
+
106
+
107
+ def classify_intent(user_text: str) -> dict:
108
+ """Classify a user turn for the answer-on-paper / verify nudges.
109
+
110
+ Returns {"action": bool, "read_only": bool}. `action` means the user wants a file
111
+ changed (so a turn that ends without an applied edit is a stall worth nudging).
112
+ `read_only` means the user explicitly asked to only explain / not change anything
113
+ (so the edit nudge must stay silent even if an action verb appears in a negation).
114
+ Pure and side-effect free so it can be unit-tested without loading a model
115
+ (see test_intent.py)."""
116
+ ut = user_text.lower()
117
+ s = ut.strip()
118
+ explanatory = s.startswith(_EXPLAIN_OPENERS)
119
+ read_only = explanatory or any(p in ut for p in _READ_ONLY_PHRASES)
120
+ action = any(w in ut for w in _ACTION_WORDS)
121
+ return {"action": action, "read_only": read_only}
122
+
123
+
124
+ # Short base prompt for a spawned sub-agent (plan 041). It runs in a fresh, isolated
125
+ # context to do ONE scoped job and report back — so it wants the same project context
126
+ # and tool discipline as the main agent, but a tighter behavioral preamble and, above
127
+ # all, the final-answer contract: its last message is returned verbatim to the caller.
128
+ _SUBAGENT_BASE = """You are a focused sub-agent spawned by chad to do ONE scoped job in \
129
+ an ISOLATED context, then report back. You act on the REAL codebase in the working \
130
+ directory by calling tools — you are not a chatbot.
131
+
132
+ # How you work
133
+ - The ONLY way to do anything is to emit a tool call inside <tool_call></tool_call> tags. A command in a ```bash code fence does NOTHING. Emit a real <tool_call>, wait for the result, then continue.
134
+ - Stay tightly scoped to the task you were given. Do not wander into unrelated files or side quests. Navigate cheaply: grep/glob to locate, repo_map/overview/view_symbol to read structure, read only what you must.
135
+ - You were spawned WITHOUT the caller's conversation. Everything you need is in the task prompt below; if something is genuinely missing, make the most reasonable assumption and note it — do not stall.
136
+ - You CANNOT delegate further (no nested sub-agents).
137
+
138
+ # Your final answer is the deliverable (CRITICAL)
139
+ - Your LAST message is returned VERBATIM to the caller as the result of their `task` call — it is the ONLY thing they see. The caller does not see your tool calls, your reasoning, or your intermediate steps.
140
+ - So make the last message count: return concrete FACTS — exact file paths with line numbers, short code excerpts, and direct answers. Be dense and specific. Do NOT narrate what you did ("I looked at…", "then I searched…"); state the findings.
141
+ - When you have the answer, call `done` with your findings as the summary."""
142
+
143
+
144
+ def _dynamic_context() -> list:
145
+ """The volatile, per-session tail of the system prompt (cwd, workspace snapshot,
146
+ test command, project docs, skills catalog). Shared by the main and sub-agent
147
+ prompt builders so a sub-agent gets the same project grounding below its own
148
+ (different) behavioral preamble."""
149
+ dynamic = [
150
+ "\n\n# Environment",
151
+ f"- OS: {platform.system()} {platform.release()} ({platform.machine()})",
152
+ f"- Shell: {os.environ.get('SHELL', 'unknown')}",
153
+ f"- Working directory: {os.getcwd()}",
154
+ ]
155
+ snapshot = _workspace_snapshot()
156
+ if snapshot:
157
+ dynamic.append(
158
+ "\n# Workspace files (a real project — use grep/read to inspect before answering)\n"
159
+ + snapshot
160
+ )
161
+ test_cmd = _detect_test_command()
162
+ if test_cmd:
163
+ dynamic.append(
164
+ "\n# Running this project's tests\n"
165
+ f"- This project's tests run with: `{test_cmd}`\n"
166
+ "- Use that exact command to verify your changes. Do NOT rediscover the "
167
+ "runner by trial-and-error and do NOT install packages."
168
+ )
169
+ for fname in ("CLAUDE.md", "AGENTS.md"):
170
+ if os.path.isfile(fname):
171
+ try:
172
+ # errors="replace": these are prompt text, not code we execute — a
173
+ # non-UTF-8 project doc must not crash build_system_prompt (agent won't
174
+ # construct); replacement chars in a stray byte are harmless here.
175
+ doc = open(fname, encoding="utf-8", errors="replace").read().strip()[:4000]
176
+ except OSError:
177
+ continue
178
+ if doc:
179
+ dynamic.append(f"\n# Project instructions ({fname})\n{doc}")
180
+ break
181
+ # Agent Skills catalog (tier-1 disclosure): name+description+location for every
182
+ # installed skill, plus how to activate one. Empty string when none are installed.
183
+ from . import skills
184
+ catalog = skills.catalog_block()
185
+ if catalog:
186
+ dynamic.append(catalog)
187
+ return dynamic
188
+
189
+
190
+ def build_system_prompt() -> str:
191
+ # Cache-boundary trick (from the Claude Code teardown): everything above the
192
+ # boundary is static behavioral text that stays identical across sessions, so the
193
+ # prefix KV cache reuses it. Volatile per-session context (cwd, project docs) goes
194
+ # below, where re-prefilling a few hundred tokens is cheap.
195
+ return _BASE_PROMPT + "\n".join(_dynamic_context())
196
+
197
+
198
+ def build_subagent_prompt() -> str:
199
+ """The system prompt for a spawned sub-agent: the tight sub-agent preamble + the
200
+ same per-session project context the main agent gets. Its own stable head means the
201
+ sub-agent warm-prefixes to its OWN disk checkpoint (plan 041), so repeated tasks in a
202
+ session skip re-prefilling this prefix."""
203
+ return _SUBAGENT_BASE + "\n".join(_dynamic_context())
204
+
205
+
206
+ # A test-runner invocation we can lift verbatim from CI / Make config. Anchored at the
207
+ # command verb so a leading `run: ` / `- ` yaml prefix is excluded; captures to EOL.
208
+ _TEST_CMD_RE = re.compile(
209
+ r'((?:uv run |poetry run |pipenv run |pdm run |hatch run )?'
210
+ r'(?:python[0-9.]*\s+-m\s+(?:pytest|unittest)\b[^\n\r]*'
211
+ r'|pytest\b[^\n\r]*'
212
+ r'|tox\b[^\n\r]*'
213
+ r'|make\s+test\b'
214
+ r'|npm\s+(?:run\s+)?test\b'
215
+ r'|yarn\s+test\b'
216
+ r'|cargo\s+test\b[^\n\r]*'
217
+ r'|go\s+test\b[^\n\r]*))'
218
+ )
219
+
220
+
221
+ def _first_test_cmd(path: str) -> str:
222
+ """First test-runner invocation found in a CI/Make file, cleaned, or ""."""
223
+ try:
224
+ txt = open(path, encoding="utf-8", errors="replace").read()
225
+ except OSError:
226
+ return ""
227
+ m = _TEST_CMD_RE.search(txt)
228
+ if not m:
229
+ return ""
230
+ cmd = m.group(1).strip().strip('"\'').split(" #", 1)[0].strip()
231
+ return cmd if 0 < len(cmd) <= 120 else ""
232
+
233
+
234
+ def _detect_test_command() -> str:
235
+ """Best-effort: the command this project uses to run its tests, lifted from CI
236
+ config / Makefile / build files in cwd. Surfaced in the system prompt so the model
237
+ doesn't burn its step budget rediscovering the runner by trial-and-error — the
238
+ demonstrated failure was ~20 steps fighting pytest/unittest/uv/PYTHONPATH before
239
+ finally reading .github/workflows/ci.yml. Returns "" when nothing recognizable is
240
+ present (the model then falls back to the prompt's generic guidance)."""
241
+ # 1) CI workflows are the most authoritative: it's the command that actually passes
242
+ # in this repo, with the right runner prefix (uv run, poetry run, ...).
243
+ for path in sorted(glob.glob(".github/workflows/*.yml")
244
+ + glob.glob(".github/workflows/*.yaml")):
245
+ cmd = _first_test_cmd(path)
246
+ if cmd:
247
+ return cmd
248
+ # 2) A Makefile `test:` target.
249
+ if os.path.isfile("Makefile"):
250
+ try:
251
+ if re.search(r'^test:', open("Makefile", encoding="utf-8", errors="replace").read(), re.MULTILINE):
252
+ return "make test"
253
+ except OSError:
254
+ pass
255
+ # 3) Build-file fallbacks. pytest config implies pytest; prefer `uv run` when the
256
+ # project is uv-managed (bare `python` would miss the project's venv).
257
+ if os.path.isfile("pyproject.toml"):
258
+ try:
259
+ txt = open("pyproject.toml", encoding="utf-8", errors="replace").read()
260
+ except OSError:
261
+ txt = ""
262
+ if "[tool.pytest" in txt:
263
+ return ("uv run python -m pytest" if os.path.isfile("uv.lock")
264
+ else "python -m pytest")
265
+ if os.path.isfile("Cargo.toml"):
266
+ return "cargo test"
267
+ if os.path.isfile("go.mod"):
268
+ return "go test ./..."
269
+ if os.path.isfile("package.json"):
270
+ try:
271
+ if re.search(r'"test"\s*:', open("package.json", encoding="utf-8", errors="replace").read()):
272
+ return "npm test"
273
+ except OSError:
274
+ pass
275
+ return ""
276
+
277
+
278
+ def _workspace_snapshot(limit: int = 60) -> str:
279
+ """A short listing of the project's code files, so the model knows it's working
280
+ in a real repo (Claude Code injects similar context). Prefers git-tracked files."""
281
+ import subprocess
282
+ files = []
283
+ try:
284
+ proc = subprocess.run(["git", "ls-files"], capture_output=True, text=True, timeout=5)
285
+ if proc.returncode == 0:
286
+ files = [f for f in proc.stdout.splitlines() if f.strip()]
287
+ except (OSError, subprocess.SubprocessError):
288
+ pass
289
+ if not files:
290
+ import glob as _g
291
+ code_ext = (".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".rb",
292
+ ".c", ".h", ".cpp", ".sh", ".md", ".toml", ".yaml", ".yml", ".json")
293
+ files = [
294
+ f for f in _g.glob("**/*", recursive=True)
295
+ if os.path.isfile(f) and f.endswith(code_ext)
296
+ and ".git/" not in f and "node_modules/" not in f and "__pycache__" not in f
297
+ ]
298
+ if not files:
299
+ return ""
300
+ files = sorted(files)
301
+ shown = files[:limit]
302
+ out = "\n".join(shown)
303
+ if len(files) > limit:
304
+ out += f"\n... (+{len(files) - limit} more; use glob/grep to find others)"
305
+ return out