kidwit 0.1.0rc1__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.
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: kidwit
3
+ Version: 0.1.0rc1
4
+ Summary: A minimal coding agent in one file of standard-library Python. It works inside one directory and asks before it changes anything.
5
+ Author-email: SM Sabbir Amin <sabbiramin.cse11ruet@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://kidwit.sabbiramin.dev
8
+ Project-URL: Source, https://github.com/sabbiramin113008/kidwit
9
+ Project-URL: Issues, https://github.com/sabbiramin113008/kidwit/issues
10
+ Project-URL: Changelog, https://github.com/sabbiramin113008/kidwit/blob/main/CHANGELOG.md
11
+ Keywords: coding-agent,ai-agent,llm,cli,anthropic,openai,deepseek,ollama
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # kidwit
30
+
31
+ A complete coding-agent engine in one file of standard-library Python, around 500 lines of code. It works inside one directory and asks before it changes anything.
32
+
33
+ kidwit reads your code, edits files, runs commands and reports back, using native tool calling with Anthropic or any OpenAI-compatible API. It is a from-scratch build and a case study in how little a coding agent needs. It is not a replacement for mature agents.
34
+
35
+ ## Install
36
+
37
+ You need Python 3.9 or newer. kidwit has no dependencies. From a clone of this repository:
38
+
39
+ ```
40
+ pipx install . # or: pip install .
41
+ export ANTHROPIC_API_KEY=sk-ant-...
42
+ kidwit "fix the failing test"
43
+ ```
44
+
45
+ The whole engine is one file, so you can also run it with no install at all:
46
+
47
+ ```
48
+ python3 src/kidwit.py "fix the failing test"
49
+ ```
50
+
51
+ Without a task, kidwit starts an interactive session and, on a color terminal, shows a small banner. The banner never appears with `--json`, with a task on the command line, or when output is piped. `/clear` resets the conversation, `/verbose` toggles detailed output, and `/exit` (or `/quit`, or end of input) leaves.
52
+
53
+ ## Status
54
+
55
+ kidwit is early software. This is what has and has not been tested:
56
+
57
+ 1. **Tested live:** the OpenAI-compatible adapter with DeepSeek (`deepseek-chat`) on macOS with Python 3.14. The 8 tasks in `evals/` all pass end to end.
58
+ 2. **Not yet run against the real service:** the Anthropic adapter. It is covered by the offline tests, but nobody has run it against the live Anthropic API yet.
59
+ 3. **Untested:** Windows. The command runner uses Python's default shell there (`cmd.exe`), and killing a timed-out command's child processes is only verified on macOS and Linux-style systems. Linux and Python 3.9 to 3.13 are also untested for now.
60
+
61
+ If you try it somewhere on this list, please open an issue with what happened.
62
+
63
+ ## Configuration
64
+
65
+ Environment variables:
66
+
67
+ | Variable | Meaning |
68
+ |---|---|
69
+ | `ANTHROPIC_API_KEY` | Key for the Anthropic Messages API. If set, Anthropic is the default provider. |
70
+ | `OPENAI_API_KEY` | Key for an OpenAI-compatible API. Optional for local servers. |
71
+ | `AGENT_PROVIDER` | `anthropic` or `openai`. |
72
+ | `AGENT_MODEL` | Model name. Defaults to `claude-sonnet-5-5` for Anthropic. Required for `openai`. |
73
+ | `AGENT_BASE_URL` | API base URL, for OpenRouter, Ollama, LM Studio, vLLM or a proxy. |
74
+
75
+ Provider choice follows this order: `--provider`, then `AGENT_PROVIDER`, then Anthropic if `ANTHROPIC_API_KEY` is set, then OpenAI-compatible.
76
+
77
+ Local model example:
78
+
79
+ ```
80
+ AGENT_BASE_URL=http://localhost:11434/v1 AGENT_MODEL=qwen2.5-coder kidwit "add a docstring to main()"
81
+ ```
82
+
83
+ Flags:
84
+
85
+ | Flag | Meaning |
86
+ |---|---|
87
+ | `--root DIR` | Project directory. Default: the current directory. |
88
+ | `--provider anthropic\|openai` | Choose the API. |
89
+ | `--model M`, `--base-url URL` | Override `AGENT_MODEL` and `AGENT_BASE_URL`. |
90
+ | `--yes` | Never ask for approval. |
91
+ | `--read-only` | Offer only the read tools. Writes and commands are not available. |
92
+ | `--json` | Print JSON-lines events instead of text. |
93
+ | `--verbose` | Show live command output and full tool arguments. By default kidwit prints one short dim line per tool call and then the answer. |
94
+ | `--no-log` | Do not write `.agent/audit.jsonl`. |
95
+ | `--version` | Print the version. |
96
+
97
+ A task given on the command line runs once. The exit code is 0 on success and 1 on error, so it works in scripts and benchmarks.
98
+
99
+ ## Reading the terminal
100
+
101
+ On a color terminal each kind of text looks different. Your prompt is a bold green `>>`. The model's replies have a magenta `▌` gutter. Tool calls are one dim line with the tool name in cyan. Permission requests start with a yellow `┌ permission needed` line, followed by the diff or command and a yellow `└ allow?` prompt. Errors are red. With `NO_COLOR`, piped output or `--json`, none of this styling is printed.
102
+
103
+ ## Tools
104
+
105
+ The model gets six tools: `list_files`, `search`, `read_file`, `write_file`, `edit_file` and `run`. Inside a git repository, `list_files` and `search` honour `.gitignore`, so build output and similar files stay out of the way. Edits are exact-string replacement: a match that differs only in trailing whitespace is accepted, and when nothing matches the error names the closest line so the model can correct itself. `old_text` must match exactly once unless `replace_all` is set, and files with Windows line endings keep them.
106
+
107
+ If the project root has an `AGENTS.md` (or else a `CLAUDE.md`), its first 20,000 characters are added to the system prompt.
108
+
109
+ ## Approval modes
110
+
111
+ 1. `ask` (default). Before every `write_file`, `edit_file` and `run`, kidwit shows the diff or command and waits for `y`, `a`, `n`, or any other text. Other text is sent to the model as the reason for the denial. `a` (always) approves that kind of action for the rest of the session: file changes, or commands, but not both.
112
+ 2. `auto` (`--yes`). No prompts, except for dangerous commands.
113
+ 3. `read-only` (`--read-only`). The write tools are not offered to the model, and are refused if called anyway.
114
+
115
+ Some things are handled for you in every mode:
116
+
117
+ 1. In `ask` mode, harmless commands run without a prompt: `ls`, `pwd`, `git status`, `git diff`, `git log`, `git show`, `git branch`, and test runners such as `python3 -m unittest`, `pytest`, `npm test`, `go test` and `cargo test`. They must use plain relative arguments with no shell operators. `cat`, `grep` and `head` are not on the list on purpose: the path jail does not cover `run`, so they could read files outside the project.
118
+ 2. Dangerous commands always ask, even with `--yes` or after `a`: `rm -rf`, `sudo`, `curl ... | sh`, `git reset --hard`, `git clean`, `git push`, `chmod -R` and similar. With no one to answer (for example `--json` with closed stdin) they are denied.
119
+ 3. `/undo` in an interactive session reverts the last file write or edit. It does not undo commands.
120
+ 4. If the model's reply is cut off at the token limit, the half-written tool call is not run. The model is told to do less per call.
121
+
122
+ Every write, edit and command is appended to `<root>/.agent/audit.jsonl`. kidwit creates `.agent/.gitignore` so the log stays out of git.
123
+
124
+ ## Limits you should know about
125
+
126
+ The path jail covers the file tools only. `read_file`, `write_file` and `edit_file` refuse paths outside the project root, including `../` and symlinks that lead out, and `list_files` and `search` skip such symlinks. `run` executes real shell commands, and those can reach anything your user account can reach. The protections in v1 are the approval prompt and `--read-only`. There is no OS-level sandbox, so review each command before you approve it, and avoid `--yes` on a machine or directory you care about.
127
+
128
+ Long sessions are kept within the model's context in two steps. First, old tool output is dropped. If the history is still too large (over about 200,000 characters), kidwit asks the model to summarize the older half of the conversation and continues from that summary.
129
+
130
+ Other deliberate omissions: no streaming of model responses (command output does stream), no sub-agents, no MCP, no saved sessions, and no UI beyond plain terminal text.
131
+
132
+ ## Event protocol
133
+
134
+ With `--json`, kidwit writes one JSON object per line on stdout. Tasks and approval answers are read from stdin, one line each. End of input during an approval counts as "no". A UI can drive the engine through this protocol.
135
+
136
+ | `type` | Fields |
137
+ |---|---|
138
+ | `info` | `message` |
139
+ | `text` | `text` |
140
+ | `tool` | `id`, `name`, `input` |
141
+ | `output` | `line` (live command output) |
142
+ | `approval` | `action` (`write`, `edit` or `run`), `target`, `detail` (diff or command) |
143
+ | `result` | `id`, `output`, `is_error` |
144
+ | `done` | `turns`, `input_tokens`, `output_tokens` |
145
+ | `error` | `message` |
146
+
147
+ ## Tests
148
+
149
+ The tests use only `unittest` and need no API key. A fake local HTTP server stands in for both APIs.
150
+
151
+ ```
152
+ scripts/check.sh # everything below in one go
153
+ python3 -m unittest discover -s tests -t . # just the tests
154
+ ```
155
+
156
+ `scripts/check.sh` checks the Python version, compiles `src/kidwit.py`, confirms it imports only the standard library, runs the tests, and checks the size (`grep -vcE '^\s*(#.*)?$' src/kidwit.py`). Over 500 lines prints a warning, and over 700 fails.
157
+
158
+ ## Evals
159
+
160
+ `evals/` holds 8 small tasks, each a tiny repo with a failing check: an off-by-one, a name error, implementing a function, a root cause in a second file, a rename across files, a CRLF file, a missing config default, and a new CLI flag. They show whether a change to kidwit makes it better or worse.
161
+
162
+ ```
163
+ python3 evals/run_evals.py --selftest # checks the tasks themselves, no model needed
164
+ python3 evals/run_evals.py # runs every task against your configured model
165
+ python3 evals/run_evals.py --task crlf-file -- --provider openai --model deepseek-chat
166
+ ```
167
+
168
+ The runner prints pass or fail, turns, tokens and time per task, and saves the results to `evals/results/`. Be aware that it runs the agent with `--yes`, so the model's commands run as you with no sandbox. Dangerous commands still need approval, and because the runner closes stdin they are denied. Run it only where that is acceptable.
169
+
170
+ ## License
171
+
172
+ MIT.
@@ -0,0 +1,7 @@
1
+ kidwit.py,sha256=sCAd3Gb4Vha4966IM3Ti6MupFp7zCJZEwCsPiR-l7rE,32991
2
+ kidwit-0.1.0rc1.dist-info/licenses/LICENSE,sha256=denLz2gy8k9sJNHcUmaAWza01zaNnyV-8ZRvJ-F95SE,1071
3
+ kidwit-0.1.0rc1.dist-info/METADATA,sha256=KKe7IE9wEQnyP3VHWCB2cYkQhS498PezMnIKPjtbYX0,10480
4
+ kidwit-0.1.0rc1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ kidwit-0.1.0rc1.dist-info/entry_points.txt,sha256=ha0h53KwshW7ODje9kvDu4uv936vupjAZ1GhnY1fQ5A,39
6
+ kidwit-0.1.0rc1.dist-info/top_level.txt,sha256=YLig_nM4SUI3yhhG2uqcp1BfSX7AOQiNSU0ssZEkW4I,7
7
+ kidwit-0.1.0rc1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kidwit = kidwit:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SM Sabbir Amin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ kidwit
kidwit.py ADDED
@@ -0,0 +1,684 @@
1
+ #!/usr/bin/env python3
2
+ """kidwit: a minimal coding-agent engine. Standard library only.
3
+
4
+ Usage: kidwit "fix the failing test" (no task: REPL; --json: JSON-lines events)
5
+ Also runs as a single file: python3 kidwit.py "fix the failing test"
6
+ """
7
+ import argparse
8
+ import difflib
9
+ import json
10
+ import os
11
+ import platform
12
+ import re
13
+ import signal
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ VERSION = __version__ = "0.1.0rc1"
23
+ MAX_TURNS = 50
24
+ MAX_TOKENS = 8192
25
+ MAX_TOOL_OUTPUT = 12000
26
+ CONTEXT_BUDGET = 200000
27
+ DEFAULT_TIMEOUT = 120
28
+ DEFAULT_MODEL = "claude-sonnet-5-5"
29
+ BACKOFF = 2
30
+ RETRY_CODES = {408, 429, 500, 502, 503, 504, 529}
31
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
32
+ ".agent", ".next", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".cache"}
33
+ OLD_OUTPUT = "[old output removed to save context]"
34
+ SUMMARY_SYSTEM = ("You summarize a coding agent's earlier work so it can continue after the old messages are dropped. "
35
+ "Write compact plain text: the user's task and later instructions, files read or changed and why, "
36
+ "commands run and their results, decisions made, and what is still left to do. Keep exact file "
37
+ "paths, names and error messages. No preamble.")
38
+ # Commands that run without asking in ask mode: read-only inspection and test runners. Deliberately
39
+ # no cat/grep/head: the path jail covers the file tools, not `run`, so those could read outside the root.
40
+ CUT_OFF = {"_error": "your reply hit the output token limit and this call was cut off, so it did not run; "
41
+ "do less per call, for example edit_file instead of rewriting a whole file"}
42
+ SAFE_CMD = re.compile(r"(ls|pwd|git (status|diff|log|show|branch)|python3? -m (unittest|pytest)|pytest"
43
+ r"|npm test|go test|cargo test)( [\w:./-]+)*")
44
+ # Commands that always ask, even with --yes or after "always".
45
+ DANGER = re.compile(r"\brm\s+-\w*[rf]|\bsudo\b|\bmkfs|\bdd\s+if=|\bch(mod|own)\s+-\w*R|\|\s*(ba|z)?sh\b"
46
+ r"|>\s*/dev/|\bgit\s+(reset\s+--hard|clean|push)\b")
47
+ BANNER = " █▄▀ █ █▀▄ █ █ █ █ ▀█▀\n █ █ █ █▄▀ ▀▄▀▄▀ █ █ "
48
+
49
+ SYSTEM = """You are kidwit, a coding agent working inside one project directory.
50
+ Project root: {root} OS: {os} Shell: {shell}
51
+
52
+ Explore first: use list_files and search to find the right files, and read_file before you change anything.
53
+ Make the smallest change that solves the task. Prefer edit_file, copying old_text exactly with enough context to match once.
54
+ Never guess file contents or paths. If a tool call fails, read the error and fix the call instead of repeating it.
55
+ After changing code, run the tests or a relevant command to check your work, and fix anything you broke.
56
+ Paths are relative to the project root. If the user denies an action, adapt to their feedback and do not retry it.
57
+ If the request is ambiguous or risky, ask a short question instead of guessing.
58
+ Be concise. When done, say what you changed and the result."""
59
+
60
+
61
+ class AgentError(Exception):
62
+ """Fatal problem for the current task (API failure, bad configuration)."""
63
+
64
+
65
+ class ToolError(Exception):
66
+ """A tool failure the model should read and recover from."""
67
+
68
+
69
+ def clip(text, limit=MAX_TOOL_OUTPUT):
70
+ if len(text) <= limit:
71
+ return text
72
+ head = limit * 2 // 3
73
+ return f"{text[:head]}\n...[{len(text) - limit} chars clipped]...\n{text[-(limit - head):]}"
74
+
75
+
76
+ def _tool(name, desc, props, required=()):
77
+ schema = {k: {"type": t} for k, t in props.items()}
78
+ return {"name": name, "description": desc,
79
+ "input_schema": {"type": "object", "properties": schema, "required": list(required)}}
80
+
81
+
82
+ GLOB = "pattern is an optional glob such as '*.py' or 'src/**/*.py'."
83
+ TOOLS = [
84
+ _tool("list_files", f"List project files. {GLOB}", {"pattern": "string"}),
85
+ _tool("search", f"Search file contents with a Python regex; returns path:line: text. {GLOB}",
86
+ {"regex": "string", "pattern": "string"}, ["regex"]),
87
+ _tool("read_file", "Read a file with line numbers. offset is the 1-based first line; "
88
+ "limit defaults to 2000.", {"path": "string", "offset": "integer", "limit": "integer"}, ["path"]),
89
+ _tool("write_file", "Create or overwrite a file.",
90
+ {"path": "string", "content": "string"}, ["path", "content"]),
91
+ _tool("edit_file", "Replace exact text in a file. old_text must match exactly once "
92
+ "unless replace_all is true.",
93
+ {"path": "string", "old_text": "string", "new_text": "string", "replace_all": "boolean"},
94
+ ["path", "old_text", "new_text"]),
95
+ _tool("run", "Run a shell command in the project root. timeout is in seconds (default 120).",
96
+ {"command": "string", "timeout": "integer"}, ["command"]),
97
+ ]
98
+ WRITE_TOOLS = {"write_file", "edit_file", "run"}
99
+
100
+
101
+ def post_json(url, headers, body):
102
+ """POST JSON and return the parsed reply. Retries transient failures with backoff."""
103
+ data, err = json.dumps(body).encode(), "unknown error"
104
+ for attempt in range(4):
105
+ req = urllib.request.Request(url, data, {"Content-Type": "application/json", **headers})
106
+ try:
107
+ with urllib.request.urlopen(req, timeout=300) as resp:
108
+ return json.loads(resp.read())
109
+ except urllib.error.HTTPError as e:
110
+ with e:
111
+ detail = e.read().decode("utf-8", errors="replace")[:1500]
112
+ if e.code not in RETRY_CODES:
113
+ raise AgentError(f"HTTP {e.code}: {detail}")
114
+ err = f"HTTP {e.code}: {detail}"
115
+ except ValueError:
116
+ raise AgentError("the API returned a reply that is not valid JSON")
117
+ except OSError as e:
118
+ err = str(e)
119
+ if attempt < 3:
120
+ time.sleep(BACKOFF * 2 ** attempt)
121
+ raise AgentError(f"request failed after 4 attempts: {err}")
122
+
123
+
124
+ class Provider:
125
+ """chat(system, history, tools) -> (text, calls, (tokens_in, tokens_out)); calls are {id, name, input} dicts."""
126
+
127
+ def __init__(self, key, model, base=None):
128
+ self.key, self.model, self.base = key, model, (base or self.default_base).rstrip("/")
129
+
130
+
131
+ class AnthropicProvider(Provider):
132
+ default_base = "https://api.anthropic.com"
133
+
134
+ @staticmethod
135
+ def convert(history):
136
+ """Neutral history -> API messages, merging consecutive same-role messages."""
137
+ msgs = []
138
+ for m in history:
139
+ if m["role"] == "user":
140
+ role, blocks = "user", [{"type": "text", "text": m["text"]}]
141
+ elif m["role"] == "assistant":
142
+ role = "assistant"
143
+ blocks = [{"type": "text", "text": m["text"]}] if m["text"] else []
144
+ blocks += [{"type": "tool_use", **c} for c in m.get("calls", [])]
145
+ else:
146
+ role = "user"
147
+ blocks = [{"type": "tool_result", "tool_use_id": r["id"], "content": r["output"],
148
+ "is_error": r["is_error"]} for r in m["results"]]
149
+ if msgs and msgs[-1]["role"] == role:
150
+ msgs[-1]["content"] += blocks
151
+ else:
152
+ msgs.append({"role": role, "content": blocks})
153
+ return msgs
154
+
155
+ def chat(self, system, history, tools):
156
+ cache = {"type": "ephemeral"}
157
+ msgs = self.convert(history)
158
+ if msgs:
159
+ msgs[-1]["content"][-1]["cache_control"] = cache
160
+ body = {"model": self.model, "max_tokens": MAX_TOKENS, "messages": msgs,
161
+ "system": [{"type": "text", "text": system, "cache_control": cache}]}
162
+ if tools:
163
+ body["tools"] = tools
164
+ headers = {"x-api-key": self.key, "anthropic-version": "2023-06-01"}
165
+ reply = post_json(f"{self.base}/v1/messages", headers, body)
166
+ blocks = reply.get("content") or []
167
+ text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
168
+ calls = [{"id": b["id"], "name": b["name"], "input": b.get("input") or {}}
169
+ for b in blocks if b.get("type") == "tool_use"]
170
+ if reply.get("stop_reason") == "max_tokens":
171
+ calls = [{**c, "input": CUT_OFF} for c in calls]
172
+ u = reply.get("usage") or {}
173
+ tokens_in = sum(u.get(k, 0) for k in ("input_tokens", "cache_read_input_tokens",
174
+ "cache_creation_input_tokens"))
175
+ return text, calls, (tokens_in, u.get("output_tokens", 0))
176
+
177
+
178
+ class OpenAIProvider(Provider):
179
+ """OpenAI-compatible Chat Completions (OpenAI, OpenRouter, Ollama, LM Studio, vLLM)."""
180
+ default_base = "https://api.openai.com/v1"
181
+
182
+ @staticmethod
183
+ def convert(system, history):
184
+ msgs = [{"role": "system", "content": system}]
185
+ for m in history:
186
+ if m["role"] == "user":
187
+ msgs.append({"role": "user", "content": m["text"]})
188
+ elif m["role"] == "assistant":
189
+ calls = m.get("calls") or []
190
+ msg = {"role": "assistant", "content": m["text"] or (None if calls else "")}
191
+ if calls:
192
+ msg["tool_calls"] = [{"id": c["id"], "type": "function", "function": {
193
+ "name": c["name"], "arguments": json.dumps(c["input"])}} for c in calls]
194
+ msgs.append(msg)
195
+ else:
196
+ msgs += [{"role": "tool", "tool_call_id": r["id"], "content": r["output"]}
197
+ for r in m["results"]]
198
+ return msgs
199
+
200
+ def chat(self, system, history, tools):
201
+ body = {"model": self.model, "messages": self.convert(system, history)}
202
+ if tools:
203
+ body["tools"] = [{"type": "function", "function": {"name": t["name"], "description": t["description"],
204
+ "parameters": t["input_schema"]}} for t in tools]
205
+ headers = {"Authorization": f"Bearer {self.key}"} if self.key else {}
206
+ reply = post_json(f"{self.base}/chat/completions", headers, body)
207
+ try:
208
+ choice = reply["choices"][0]
209
+ msg = choice["message"]
210
+ except (KeyError, IndexError, TypeError):
211
+ raise AgentError(f"unexpected API reply: {str(reply)[:500]}")
212
+ calls = []
213
+ for c in msg.get("tool_calls") or []:
214
+ raw = c["function"].get("arguments") or "{}"
215
+ try:
216
+ args = json.loads(raw)
217
+ except ValueError:
218
+ args = None
219
+ calls.append({"id": c["id"], "name": c["function"]["name"],
220
+ "input": args if isinstance(args, dict) else {"_invalid_json": raw}})
221
+ if choice.get("finish_reason") == "length":
222
+ calls = [{**c, "input": CUT_OFF} for c in calls]
223
+ u = reply.get("usage") or {}
224
+ return msg.get("content") or "", calls, (u.get("prompt_tokens", 0), u.get("completion_tokens", 0))
225
+
226
+
227
+ def glob_re(pat):
228
+ """Glob to regex: '*' and '?' stay inside a directory; '**/' also matches zero directories."""
229
+ rx = re.escape(pat).replace(r"\*\*/", "(?:.*/)?").replace(r"\*\*", ".*")
230
+ return re.compile(rx.replace(r"\*", "[^/]*").replace(r"\?", "[^/]") + r"\Z")
231
+
232
+
233
+ def kill_tree(proc):
234
+ """Kill a command and its children (a new session makes the process group killable)."""
235
+ try:
236
+ os.killpg(proc.pid, signal.SIGKILL) if os.name == "posix" else proc.kill()
237
+ except OSError:
238
+ pass
239
+
240
+
241
+ def loose_matches(text, old):
242
+ """Slices of text equal to old once trailing whitespace is ignored on every line."""
243
+ lines, want = text.splitlines(True), [line.rstrip() for line in old.splitlines()]
244
+ spans = ["".join(lines[i:i + len(want)]) for i in range(len(lines) - len(want) + 1)
245
+ if [line.rstrip() for line in lines[i:i + len(want)]] == want]
246
+ return spans if old.endswith("\n") else [span.rstrip("\r\n") for span in spans]
247
+
248
+
249
+ def diff(old, new, name):
250
+ return "".join(difflib.unified_diff(old.splitlines(True), new.splitlines(True),
251
+ f"a/{name}", f"b/{name}"))
252
+
253
+
254
+ class Tools:
255
+ def __init__(self, root, ui, mode="ask", log=True):
256
+ self.root, self.ui, self.mode, self.log = Path(root).resolve(), ui, mode, log
257
+ self.always, self.undo = set(), [] # kinds approved with "always"; (path, old bytes) per change
258
+
259
+ def available(self):
260
+ return [t for t in TOOLS if self.mode != "read-only" or t["name"] not in WRITE_TOOLS]
261
+
262
+ def call(self, name, args):
263
+ """Run a tool. Never raises: every failure becomes (message, True)."""
264
+ if self.mode == "read-only" and name in WRITE_TOOLS:
265
+ return f"{name} is disabled in read-only mode", True
266
+ fn = getattr(self, "t_" + name, None)
267
+ if fn is None:
268
+ return f"unknown tool: {name}", True
269
+ try:
270
+ if not isinstance(args, dict):
271
+ raise TypeError("arguments must be an object")
272
+ if "_invalid_json" in args:
273
+ raise ToolError("bad arguments: not valid JSON: " + str(args["_invalid_json"])[:200])
274
+ if "_error" in args:
275
+ raise ToolError(args["_error"])
276
+ return clip(fn(**args)), False
277
+ except ToolError as e:
278
+ return clip(str(e)), True
279
+ except TypeError as e:
280
+ return f"bad arguments for {name}: {e}", True
281
+ except Exception as e:
282
+ return f"{type(e).__name__}: {e}", True
283
+
284
+ def path(self, p):
285
+ """Resolve p inside the project root. Symlinks are followed, then checked."""
286
+ full = (self.root / p).resolve()
287
+ if full != self.root and self.root not in full.parents:
288
+ raise ToolError(f"path is outside the project root: {p}")
289
+ return full
290
+
291
+ def rel(self, p):
292
+ return p.relative_to(self.root).as_posix()
293
+
294
+ def listing(self):
295
+ """Project files: git's list (honours .gitignore) inside a repo, else a directory walk."""
296
+ try:
297
+ raw = subprocess.run(["git", "ls-files", "-co", "--exclude-standard", "-z"], cwd=self.root,
298
+ capture_output=True, check=True, timeout=10).stdout.decode("utf-8", "replace")
299
+ if raw:
300
+ return sorted(self.root / name for name in raw.split("\0") if name)
301
+ except (OSError, subprocess.SubprocessError):
302
+ pass
303
+ paths = []
304
+ for dirpath, dirs, files in os.walk(self.root):
305
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
306
+ paths += [Path(dirpath) / name for name in sorted(files)]
307
+ return paths
308
+
309
+ def walk(self, pattern=None):
310
+ """Yield (path, relpath) for project files, skipping SKIP_DIRS and outside symlinks."""
311
+ rx = glob_re(pattern) if pattern else None
312
+ for p in self.listing():
313
+ rel = self.rel(p)
314
+ outside = p.is_symlink() and self.root not in p.resolve().parents
315
+ if p.is_file() and not outside and not SKIP_DIRS.intersection(rel.split("/")[:-1]) \
316
+ and (rx is None or rx.match(rel if "/" in pattern else p.name)):
317
+ yield p, rel
318
+
319
+ def load(self, p):
320
+ data = p.read_bytes()
321
+ if b"\0" in data[:4096]:
322
+ raise ToolError(f"binary file: {self.rel(p)}")
323
+ return data.decode("utf-8", errors="replace")
324
+
325
+ def t_list_files(self, pattern=None):
326
+ rels = [rel for _, rel in self.walk(pattern)]
327
+ more = f"\n[truncated: {len(rels) - 1000} more files]" if len(rels) > 1000 else ""
328
+ return "\n".join(rels[:1000]) + more or "(no files)"
329
+
330
+ def t_search(self, regex, pattern=None):
331
+ try:
332
+ rx = re.compile(regex)
333
+ except re.error as e:
334
+ raise ToolError(f"invalid regex: {e}")
335
+ hits = []
336
+ for p, rel in self.walk(pattern):
337
+ try:
338
+ text = self.load(p) if p.stat().st_size <= 2_000_000 else ""
339
+ except (OSError, ToolError):
340
+ continue
341
+ for n, line in enumerate(text.splitlines(), 1):
342
+ if rx.search(line):
343
+ hits.append(f"{rel}:{n}: {line[:300]}")
344
+ if len(hits) >= 200:
345
+ return "\n".join(hits) + "\n[stopped at 200 matches]"
346
+ return "\n".join(hits) or "no matches"
347
+
348
+ def t_read_file(self, path, offset=1, limit=2000):
349
+ lines = self.load(self.path(path)).splitlines()
350
+ offset, limit = max(1, int(offset)), int(limit)
351
+ chunk = lines[offset - 1:offset - 1 + limit]
352
+ out = "\n".join(f"{i:>6}\t{line}" for i, line in enumerate(chunk, offset))
353
+ left = len(lines) - (offset - 1 + len(chunk))
354
+ if left > 0:
355
+ out += f"\n[{left} more lines; use offset={offset + len(chunk)}]"
356
+ return out or "(empty file, or offset is past the end)"
357
+
358
+ def confirm(self, action, target, detail, force=False):
359
+ """Ask before a write, edit or command. Raises ToolError if denied. force asks even in auto mode."""
360
+ kind = "run" if action == "run" else "files" # "always" covers file changes or commands, not both
361
+ if not force and (self.mode == "auto" or kind in self.always):
362
+ return
363
+ self.ui.emit("approval", action=action, target=target, detail=detail)
364
+ prompt = "└ DANGEROUS, allow?" if force else "└ allow?"
365
+ answer = (self.ui.ask(f"{prompt} [y]es / [a]lways / [n]o / or type feedback › ", "1;33") or "").strip()
366
+ low = answer.lower()
367
+ if low in ("a", "always"):
368
+ if not force:
369
+ self.always.add(kind)
370
+ elif low not in ("y", "yes"):
371
+ raise ToolError("denied by user" + (f": {answer}" if low not in ("", "n", "no") else ""))
372
+
373
+ def audit(self, action, target):
374
+ """Append a JSON line to .agent/audit.jsonl. Failures never break the agent."""
375
+ if not self.log:
376
+ return
377
+ try:
378
+ folder = self.root / ".agent"
379
+ folder.mkdir(exist_ok=True)
380
+ (folder / ".gitignore").write_text("*\n")
381
+ entry = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "action": action, "target": target}
382
+ with (folder / "audit.jsonl").open("a", encoding="utf-8") as f:
383
+ f.write(json.dumps(entry) + "\n")
384
+ except Exception:
385
+ pass
386
+
387
+ def save(self, p, text):
388
+ self.undo.append((p, p.read_bytes() if p.exists() else None))
389
+ p.parent.mkdir(parents=True, exist_ok=True)
390
+ p.write_bytes(text.encode("utf-8")) # bytes keep line endings exactly
391
+
392
+ def revert(self):
393
+ """Undo the most recent write or edit. Commands cannot be undone."""
394
+ if not self.undo:
395
+ return "nothing to undo"
396
+ p, old = self.undo.pop()
397
+ p.unlink() if old is None else p.write_bytes(old)
398
+ return f"reverted {self.rel(p)}"
399
+
400
+ def t_write_file(self, path, content):
401
+ p = self.path(path)
402
+ rel = self.rel(p)
403
+ old = self.load(p) if p.exists() else None
404
+ detail = diff(old, content, rel) if old is not None else content[:2000]
405
+ self.confirm("write", rel, detail)
406
+ self.audit("write", rel)
407
+ self.save(p, content)
408
+ return f"wrote {len(content)} chars to {rel}"
409
+
410
+ def t_edit_file(self, path, old_text, new_text, replace_all=False):
411
+ p = self.path(path)
412
+ rel, text = self.rel(p), self.load(p)
413
+ if not old_text:
414
+ raise ToolError("old_text must not be empty")
415
+ if old_text not in text and "\r\n" in text:
416
+ old_text = old_text.replace("\r\n", "\n").replace("\n", "\r\n")
417
+ new_text = new_text.replace("\r\n", "\n").replace("\n", "\r\n")
418
+ count = text.count(old_text)
419
+ if count == 0 and len(spans := loose_matches(text, old_text)) == 1:
420
+ old_text, count = spans[0], 1
421
+ if count == 0:
422
+ lines = [line.strip() for line in text.splitlines()]
423
+ near = difflib.get_close_matches(old_text.strip().split("\n")[0].strip(), lines, 1, 0.6)
424
+ hint = f"; the closest line is {lines.index(near[0]) + 1}: {near[0]!r}" if near else ""
425
+ raise ToolError(f"old_text not found in {rel}{hint}. Re-read the file and copy it exactly")
426
+ if count > 1 and not replace_all:
427
+ raise ToolError(f"old_text matches {count} places in {rel}; add surrounding "
428
+ "context to make it unique, or set replace_all")
429
+ new = text.replace(old_text, new_text, -1 if replace_all else 1)
430
+ self.confirm("edit", rel, diff(text, new, rel))
431
+ self.audit("edit", rel)
432
+ self.save(p, new)
433
+ return f"edited {rel} ({count if replace_all else 1} replacement(s))"
434
+
435
+ def t_run(self, command, timeout=DEFAULT_TIMEOUT):
436
+ timeout = float(timeout)
437
+ danger = bool(DANGER.search(command))
438
+ if danger or not (SAFE_CMD.fullmatch(command) and ".." not in command and " /" not in command):
439
+ self.confirm("run", command, command, force=danger)
440
+ self.audit("run", command)
441
+ proc = subprocess.Popen(command, shell=True, cwd=self.root, stdin=subprocess.DEVNULL,
442
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8",
443
+ errors="replace", start_new_session=os.name == "posix",
444
+ env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) # no stale .pyc after fast edits
445
+ expired = []
446
+ timer = threading.Timer(timeout, lambda: (expired.append(True), kill_tree(proc)))
447
+ timer.start()
448
+ lines = []
449
+ try:
450
+ for line in proc.stdout:
451
+ lines.append(line)
452
+ self.ui.emit("output", line=line.rstrip("\n"))
453
+ proc.wait()
454
+ except BaseException:
455
+ kill_tree(proc)
456
+ raise
457
+ finally:
458
+ timer.cancel()
459
+ proc.stdout.close()
460
+ status = f"[timed out after {timeout:g}s and was killed]" if expired else f"[exit code {proc.returncode}]"
461
+ return status + "\n" + "".join(lines)
462
+
463
+
464
+ class UI:
465
+ """Terminal output, or JSON-lines events with --json (see the event table in the README)."""
466
+
467
+ def __init__(self, json_mode=False, verbose=False):
468
+ self.json, self.verbose = json_mode, verbose
469
+ self.color = not json_mode and sys.stdout.isatty() and "NO_COLOR" not in os.environ
470
+
471
+ def paint(self, code, text):
472
+ return f"\033[{code}m{text}\033[0m" if self.color else text
473
+
474
+ def paint_diff(self, text):
475
+ codes = {"+": "32", "-": "31", "@": "36"}
476
+ return "\n".join(self.paint(codes.get(line[:1], "0"), line) for line in text.splitlines())
477
+
478
+ def banner(self):
479
+ if self.color:
480
+ print("\n".join(self.paint(f"1;38;5;{c}", row) for c, row in zip((205, 141), BANNER.split("\n"))))
481
+
482
+ def emit(self, kind, **d):
483
+ if self.json:
484
+ print(json.dumps({"type": kind, **d}), flush=True)
485
+ elif kind == "text":
486
+ print("\n" + "\n".join(self.paint("35", "▌ ") + line for line in d["text"].splitlines())
487
+ if self.color else "\n" + d["text"])
488
+ elif kind == "info":
489
+ print(self.paint("2", d["message"]))
490
+ elif kind == "tool":
491
+ args = json.dumps(d["input"])[:120 if self.verbose else 60]
492
+ print(self.paint("2", "- ") + self.paint("36", d["name"]) + self.paint("2", " " + args))
493
+ elif kind == "output" and self.verbose:
494
+ print(self.paint("2", " " + d["line"]), flush=True)
495
+ elif kind == "approval":
496
+ print(self.paint("1;33", f"┌ permission needed: {d['action']} {d['target']}"))
497
+ print(" " + d["detail"] if d["action"] == "run" else self.paint_diff(d["detail"]))
498
+ elif kind == "result" and d["is_error"]:
499
+ print(self.paint("31", "x " + d["output"].split("\n")[0][:200]))
500
+ elif kind == "done":
501
+ print(self.paint("2", "done: {turns} turns, {input_tokens} in / {output_tokens} out tokens".format(**d)))
502
+ elif kind == "error":
503
+ print(self.paint("31", "error: " + d["message"]), file=sys.stderr)
504
+
505
+ def ask(self, prompt, code="0"):
506
+ """Read one line from the user. Returns None on EOF."""
507
+ if self.json:
508
+ line = sys.stdin.readline()
509
+ return line.rstrip("\n") if line else None
510
+ if self.color and sys.stdin.isatty(): # \001..\002 tell readline the escape codes take no width
511
+ prompt = f"\001\033[{code}m\002{prompt}\001\033[0m\002"
512
+ try:
513
+ return input(prompt)
514
+ except EOFError:
515
+ return None
516
+
517
+
518
+ def transcript(messages):
519
+ """Plain-text rendering of history, with long text and outputs cut, for the summarizer."""
520
+ lines = []
521
+ for m in messages:
522
+ if m["role"] == "user":
523
+ lines.append("USER: " + m["text"][:4000])
524
+ elif m["role"] == "assistant":
525
+ calls = "".join(f"\n CALL {c['name']} {json.dumps(c['input'])[:200]}" for c in m.get("calls", []))
526
+ lines.append("ASSISTANT: " + m["text"][:1500] + calls)
527
+ else:
528
+ lines += [f" RESULT{' (error)' if r['is_error'] else ''}: {r['output'][:300]}" for r in m["results"]]
529
+ return "\n".join(lines)[:60000]
530
+
531
+
532
+ class Agent:
533
+ def __init__(self, provider, tools, ui, system):
534
+ self.provider, self.tools, self.ui, self.system = provider, tools, ui, system
535
+ self.history = []
536
+
537
+ def run(self, task):
538
+ """Run one task to completion. Returns the final text, or None on error/interrupt."""
539
+ self.history.append({"role": "user", "text": task})
540
+ tokens_in = tokens_out = 0
541
+ try:
542
+ for turn in range(1, MAX_TURNS + 1):
543
+ if self.trim() > CONTEXT_BUDGET:
544
+ self.compact()
545
+ text, calls, (n_in, n_out) = self.provider.chat(
546
+ self.system, self.history, self.tools.available())
547
+ tokens_in, tokens_out = tokens_in + n_in, tokens_out + n_out
548
+ self.history.append({"role": "assistant", "calls": calls,
549
+ "text": text or ("" if calls else "(empty response)")})
550
+ if text:
551
+ self.ui.emit("text", text=text)
552
+ if not calls:
553
+ self.ui.emit("done", turns=turn, input_tokens=tokens_in, output_tokens=tokens_out)
554
+ return text
555
+ results = []
556
+ for c in calls:
557
+ self.ui.emit("tool", id=c["id"], name=c["name"], input=c["input"])
558
+ output, is_error = self.tools.call(c["name"], c["input"])
559
+ self.ui.emit("result", id=c["id"], output=output, is_error=is_error)
560
+ results.append({"id": c["id"], "output": output, "is_error": is_error})
561
+ self.history.append({"role": "tool", "results": results})
562
+ self.ui.emit("error", message=f"stopped after {MAX_TURNS} turns without finishing")
563
+ except (KeyboardInterrupt, AgentError) as e:
564
+ self.repair()
565
+ self.ui.emit("error", message=str(e) or "interrupted")
566
+ return None
567
+
568
+ def repair(self):
569
+ """Give every tool call a result, or the next API request is rejected."""
570
+ last = self.history[-1] if self.history else {}
571
+ if last.get("role") == "assistant" and last.get("calls"):
572
+ self.history.append({"role": "tool", "results": [
573
+ {"id": c["id"], "output": "interrupted before this tool ran", "is_error": True}
574
+ for c in last["calls"]]})
575
+
576
+ def trim(self):
577
+ """Drop old tool output when the history outgrows CONTEXT_BUDGET. Last two messages stay. Returns the size."""
578
+ size = len(json.dumps(self.history))
579
+ for m in self.history[:-2]:
580
+ for r in m.get("results", []):
581
+ if size <= CONTEXT_BUDGET:
582
+ return size
583
+ if len(r["output"]) > 200:
584
+ size -= len(r["output"]) - len(OLD_OUTPUT)
585
+ r["output"] = OLD_OUTPUT
586
+ return size
587
+
588
+ def compact(self):
589
+ """Still over budget after trim(): replace the older half of the history with a model-written summary."""
590
+ cut = next((i for i in range(max(1, len(self.history) // 2), len(self.history) - 2)
591
+ if self.history[i]["role"] == "assistant"), None)
592
+ if cut is None:
593
+ return
594
+ try:
595
+ summary = self.provider.chat(SUMMARY_SYSTEM, [{"role": "user", "text": transcript(self.history[:cut])}], [])[0]
596
+ except AgentError:
597
+ return # keep going with the trimmed history
598
+ if summary.strip():
599
+ self.history[:cut] = [{"role": "user", "text": "Summary of the earlier work in this session:\n" + summary}]
600
+ self.ui.emit("info", message="summarized the earlier conversation to save context")
601
+
602
+
603
+ def build_system(root):
604
+ """System prompt plus the project's AGENTS.md or CLAUDE.md, if present."""
605
+ system = SYSTEM.format(root=root, os=f"{platform.system()} {platform.release()}",
606
+ shell=os.environ.get("SHELL") or os.environ.get("COMSPEC") or "sh")
607
+ for name in ("AGENTS.md", "CLAUDE.md"):
608
+ if (root / name).is_file():
609
+ text = (root / name).read_text(encoding="utf-8", errors="replace")
610
+ return f"{system}\n\nProject instructions from {name}:\n{text[:20000]}"
611
+ return system
612
+
613
+
614
+ def make_provider(args):
615
+ """Pick a provider: --provider, then AGENT_PROVIDER, then Anthropic if its key is set."""
616
+ env = os.environ
617
+ name = args.provider or env.get("AGENT_PROVIDER") or ("anthropic" if env.get("ANTHROPIC_API_KEY") else "openai")
618
+ model, base = args.model or env.get("AGENT_MODEL"), args.base_url or env.get("AGENT_BASE_URL")
619
+ if name == "anthropic":
620
+ if not env.get("ANTHROPIC_API_KEY"):
621
+ raise AgentError("ANTHROPIC_API_KEY is not set")
622
+ return AnthropicProvider(env["ANTHROPIC_API_KEY"], model or DEFAULT_MODEL, base)
623
+ if name != "openai":
624
+ raise AgentError(f"unknown provider: {name} (use anthropic or openai)")
625
+ if not env.get("OPENAI_API_KEY") and not base:
626
+ raise AgentError("no provider configured: set ANTHROPIC_API_KEY, or OPENAI_API_KEY, "
627
+ "or AGENT_BASE_URL for a local server")
628
+ if not model:
629
+ raise AgentError("set AGENT_MODEL or --model for the OpenAI-compatible provider")
630
+ return OpenAIProvider(env.get("OPENAI_API_KEY"), model, base)
631
+
632
+
633
+ def main(argv=None):
634
+ ap = argparse.ArgumentParser(prog="kidwit", description="A minimal coding agent.")
635
+ ap.add_argument("task", nargs="*", help="task to run once; omit for an interactive session")
636
+ ap.add_argument("--root", default=".", help="project directory (default: current)")
637
+ ap.add_argument("--provider", choices=["anthropic", "openai"])
638
+ ap.add_argument("--model")
639
+ ap.add_argument("--base-url")
640
+ mode = ap.add_mutually_exclusive_group()
641
+ mode.add_argument("--yes", action="store_true", help="never ask for approval")
642
+ mode.add_argument("--read-only", action="store_true", help="no writes or commands")
643
+ ap.add_argument("--json", action="store_true", help="emit JSON-lines events")
644
+ ap.add_argument("--verbose", action="store_true", help="show live command output and full tool calls")
645
+ ap.add_argument("--no-log", action="store_true", help="disable .agent/audit.jsonl")
646
+ ap.add_argument("--version", action="version", version=f"kidwit {VERSION}")
647
+ args = ap.parse_args(argv)
648
+ ui, root = UI(args.json, args.verbose), Path(args.root).resolve()
649
+ try:
650
+ if not root.is_dir():
651
+ raise AgentError(f"not a directory: {root}")
652
+ provider = make_provider(args)
653
+ except AgentError as e:
654
+ ui.emit("error", message=str(e))
655
+ return 1
656
+ tools = Tools(root, ui, "auto" if args.yes else "read-only" if args.read_only else "ask", not args.no_log)
657
+ bot = Agent(provider, tools, ui, build_system(root))
658
+ if args.task:
659
+ return 0 if bot.run(" ".join(args.task)) is not None else 1
660
+ ui.banner()
661
+ ui.emit("info", message=f"kidwit {VERSION} in {root}. /clear resets, /undo reverts the last edit, /verbose toggles detail, /exit quits.")
662
+ while True:
663
+ try:
664
+ line = ui.ask(">> ", "1;32")
665
+ except KeyboardInterrupt:
666
+ return 0
667
+ if line is None or line.strip() in ("/exit", "/quit"):
668
+ return 0
669
+ if line.strip() == "/clear":
670
+ bot.history.clear()
671
+ ui.emit("info", message="history cleared")
672
+ elif line.strip() == "/undo":
673
+ note = tools.revert()
674
+ bot.history.append({"role": "user", "text": f"[the user ran /undo: {note}]"})
675
+ ui.emit("info", message=note)
676
+ elif line.strip() == "/verbose":
677
+ ui.verbose = not ui.verbose
678
+ ui.emit("info", message="verbose " + ("on" if ui.verbose else "off"))
679
+ elif line.strip():
680
+ bot.run(line)
681
+
682
+
683
+ if __name__ == "__main__":
684
+ sys.exit(main())