kaizen-loop 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.
kaizen/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
kaizen/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from kaizen.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
kaizen/agent.py ADDED
@@ -0,0 +1,349 @@
1
+ import http.client
2
+ import json
3
+ import os
4
+ import signal
5
+ import socket
6
+ import subprocess
7
+ import time
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from urllib.parse import quote, urlparse
11
+
12
+ _RETRYABLE_STATUS = frozenset({502, 503, 504})
13
+ _MAX_RETRIES = 2
14
+ _RETRY_BASE_DELAY = 0.5
15
+
16
+
17
+ def _validate_server_url(url: str | None) -> None:
18
+ if url is None:
19
+ return
20
+ parsed = urlparse(url)
21
+ if parsed.scheme not in ("http", "https"):
22
+ raise ValueError(
23
+ f"Invalid server URL: {url!r}. Must start with http:// or https://"
24
+ )
25
+ if not parsed.hostname:
26
+ raise ValueError(f"Invalid server URL: {url!r}. No hostname found.")
27
+
28
+
29
+ @dataclass
30
+ class AgentResult:
31
+ output: dict
32
+ text: str = ""
33
+ input_tokens: int = 0
34
+ output_tokens: int = 0
35
+
36
+
37
+ class _Session:
38
+ def __init__(self, agent: "OpenCodeAgent", session_id: str):
39
+ self._agent = agent
40
+ self._id = session_id
41
+
42
+ @property
43
+ def id(self) -> str:
44
+ return self._id
45
+
46
+ def send(self, prompt: str, schema: dict | None = None) -> AgentResult:
47
+ try:
48
+ return self._agent._send_message(self._id, prompt, schema)
49
+ except Exception:
50
+ self._agent._abort_session(self._id)
51
+ raise
52
+
53
+
54
+ class OpenCodeAgent:
55
+ def __init__(self, bin_path: str = "opencode", server_url: str | None = None):
56
+ _validate_server_url(server_url)
57
+ self.bin_path = bin_path
58
+ self._external_server = server_url
59
+ self._process: subprocess.Popen | None = None
60
+ self._base_url: str | None = server_url
61
+ self._port: int | None = None
62
+ self._conn: http.client.HTTPConnection | None = None
63
+
64
+ def _get_conn(self) -> http.client.HTTPConnection:
65
+ if self._conn is not None:
66
+ return self._conn
67
+ url = self._base_url
68
+ if not url:
69
+ raise RuntimeError("No server URL configured")
70
+ parsed = urlparse(url)
71
+ self._conn = http.client.HTTPConnection(
72
+ parsed.hostname or "127.0.0.1",
73
+ parsed.port or 80,
74
+ timeout=300,
75
+ )
76
+ return self._conn
77
+
78
+ def _reconnect(self) -> http.client.HTTPConnection:
79
+ self._close_conn()
80
+ return self._get_conn()
81
+
82
+ def _close_conn(self) -> None:
83
+ if self._conn:
84
+ try:
85
+ self._conn.close()
86
+ except Exception:
87
+ pass
88
+ self._conn = None
89
+
90
+ def _http_request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ body: dict | None = None,
95
+ timeout: float | None = None,
96
+ max_retries: int = _MAX_RETRIES,
97
+ ) -> dict:
98
+ conn = self._get_conn()
99
+ if timeout is not None:
100
+ conn.timeout = timeout
101
+ headers = {"Accept": "application/json"}
102
+ data = None
103
+ if body is not None:
104
+ data = json.dumps(body).encode()
105
+ headers["Content-Type"] = "application/json"
106
+
107
+ last_err: Exception | None = None
108
+ for attempt in range(max_retries + 1):
109
+ try:
110
+ conn.request(method, path, body=data, headers=headers)
111
+ resp = conn.getresponse()
112
+ resp_data = resp.read()
113
+ if 200 <= resp.status < 300:
114
+ return json.loads(resp_data)
115
+ if resp.status in _RETRYABLE_STATUS and attempt < max_retries:
116
+ last_err = RuntimeError(f"HTTP {resp.status}")
117
+ time.sleep(_RETRY_BASE_DELAY * (2**attempt))
118
+ conn = self._reconnect()
119
+ continue
120
+ raise RuntimeError(
121
+ f"HTTP {resp.status}: {resp_data.decode(errors='replace')}"
122
+ )
123
+ except (
124
+ ConnectionError,
125
+ OSError,
126
+ http.client.HTTPException,
127
+ ) as e:
128
+ if attempt < max_retries:
129
+ last_err = e
130
+ time.sleep(_RETRY_BASE_DELAY * (2**attempt))
131
+ conn = self._reconnect()
132
+ continue
133
+ raise RuntimeError(
134
+ f"Request failed after {max_retries} retries: {e}"
135
+ ) from e
136
+ raise last_err # type: ignore[misc]
137
+
138
+ def _request(
139
+ self,
140
+ path: str,
141
+ method: str = "GET",
142
+ body: dict | None = None,
143
+ timeout: float = 300,
144
+ ) -> dict:
145
+ return self._http_request(method, path, body=body, timeout=timeout)
146
+
147
+ def _ensure_server(self, server_cwd: str) -> None:
148
+ if self._base_url:
149
+ self._check_external_server()
150
+ return
151
+
152
+ self._port = _get_free_port()
153
+ env = {**os.environ}
154
+ env.pop("OPENCODE_SERVER_USERNAME", None)
155
+ env.pop("OPENCODE_SERVER_PASSWORD", None)
156
+
157
+ self._process = subprocess.Popen(
158
+ [
159
+ self.bin_path,
160
+ "serve",
161
+ "--hostname",
162
+ "127.0.0.1",
163
+ "--port",
164
+ str(self._port),
165
+ "--print-logs",
166
+ ],
167
+ cwd=server_cwd,
168
+ stdin=subprocess.DEVNULL,
169
+ stdout=subprocess.PIPE,
170
+ stderr=subprocess.PIPE,
171
+ env=env,
172
+ start_new_session=True,
173
+ )
174
+ self._base_url = f"http://127.0.0.1:{self._port}"
175
+ self._wait_healthy(timeout=30)
176
+
177
+ def _check_external_server(self) -> None:
178
+ url = self._base_url
179
+ if not url:
180
+ raise RuntimeError("No server URL configured")
181
+ try:
182
+ self._http_request(
183
+ "GET", "/global/health", timeout=5, max_retries=1
184
+ )
185
+ except (
186
+ RuntimeError,
187
+ ConnectionError,
188
+ OSError,
189
+ http.client.HTTPException,
190
+ ) as e:
191
+ port = url.split(":")[-1].rstrip("/")
192
+ raise RuntimeError(
193
+ f"Shared server at {url} is not reachable. "
194
+ f"Start it with: opencode serve --hostname 127.0.0.1 --port {port} | Error: {e}"
195
+ )
196
+
197
+ def _wait_healthy(self, timeout: float = 30) -> None:
198
+ deadline = time.monotonic() + timeout
199
+ while time.monotonic() < deadline:
200
+ if self._process and self._process.poll() is not None:
201
+ raise RuntimeError("opencode server exited during startup")
202
+ try:
203
+ conn = self._reconnect()
204
+ conn.timeout = 2
205
+ conn.request("GET", "/global/health")
206
+ resp = conn.getresponse()
207
+ resp.read()
208
+ if resp.status == 200:
209
+ return
210
+ except (ConnectionError, OSError, http.client.HTTPException):
211
+ pass
212
+ time.sleep(0.25)
213
+ raise RuntimeError(
214
+ f"opencode server did not become healthy on port {self._port}"
215
+ )
216
+
217
+ def _create_session(self, session_dir: str) -> str:
218
+ resp = self._request(
219
+ f"/session?directory={quote(session_dir, safe='')}",
220
+ method="POST",
221
+ body={},
222
+ timeout=10,
223
+ )
224
+ return resp.get("id", "")
225
+
226
+ @contextmanager
227
+ def session(self, work_dir: str, repo_dir: str | None = None):
228
+ server_cwd = repo_dir or work_dir
229
+ self._ensure_server(server_cwd)
230
+ session_id = self._create_session(work_dir)
231
+ try:
232
+ yield _Session(self, session_id)
233
+ finally:
234
+ self._delete_session(session_id)
235
+
236
+ def run(
237
+ self,
238
+ prompt: str,
239
+ work_dir: str,
240
+ schema: dict | None = None,
241
+ repo_dir: str | None = None,
242
+ ) -> AgentResult:
243
+ server_cwd = repo_dir or work_dir
244
+ self._ensure_server(server_cwd)
245
+ session_id = self._create_session(work_dir)
246
+ try:
247
+ return self._send_message(session_id, prompt, schema)
248
+ except Exception:
249
+ self._abort_session(session_id)
250
+ raise
251
+ finally:
252
+ self._delete_session(session_id)
253
+
254
+ def _send_message(
255
+ self, session_id: str, prompt: str, schema: dict | None = None
256
+ ) -> AgentResult:
257
+ body: dict = {
258
+ "role": "user",
259
+ "parts": [{"type": "text", "text": prompt}],
260
+ }
261
+ if schema:
262
+ body["format"] = {
263
+ "type": "json_schema",
264
+ "schema": schema,
265
+ "retryCount": 1,
266
+ }
267
+ result = self._request(
268
+ f"/session/{session_id}/message",
269
+ method="POST",
270
+ body=body,
271
+ timeout=600,
272
+ )
273
+
274
+ info = result.get("info", {})
275
+ structured = info.get("structured")
276
+ tokens = info.get("tokens", {})
277
+
278
+ if structured:
279
+ return AgentResult(
280
+ output=structured,
281
+ input_tokens=tokens.get("input", 0),
282
+ output_tokens=tokens.get("output", 0),
283
+ )
284
+
285
+ text = ""
286
+ for part in result.get("parts", []):
287
+ if part.get("type") == "text" and part.get("text"):
288
+ text = part["text"]
289
+
290
+ if not text:
291
+ raise RuntimeError("No structured output or text in agent response")
292
+
293
+ raise RuntimeError(f"Agent returned unstructured text: {text[:200]}")
294
+
295
+ def _abort_session(self, session_id: str) -> None:
296
+ try:
297
+ self._request(
298
+ f"/session/{session_id}/abort", method="POST", timeout=3
299
+ )
300
+ except Exception:
301
+ pass
302
+
303
+ def _delete_session(self, session_id: str) -> None:
304
+ try:
305
+ self._request(
306
+ f"/session/{session_id}", method="DELETE", timeout=3
307
+ )
308
+ except Exception:
309
+ pass
310
+
311
+ def close(self) -> None:
312
+ if self._external_server:
313
+ self._close_conn()
314
+ return
315
+ if self._base_url:
316
+ try:
317
+ self._http_request(
318
+ "POST", "/instance/dispose", timeout=5, max_retries=0
319
+ )
320
+ except Exception:
321
+ pass
322
+ self._close_conn()
323
+ if self._process and self._process.poll() is None:
324
+ try:
325
+ self._process.wait(timeout=5)
326
+ except subprocess.TimeoutExpired:
327
+ try:
328
+ os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
329
+ except OSError:
330
+ self._process.terminate()
331
+ try:
332
+ self._process.wait(timeout=5)
333
+ except subprocess.TimeoutExpired:
334
+ try:
335
+ os.killpg(
336
+ os.getpgid(self._process.pid), signal.SIGKILL
337
+ )
338
+ except OSError:
339
+ self._process.kill()
340
+ self._process.wait(timeout=2)
341
+ self._process = None
342
+ self._base_url = None
343
+ self._port = None
344
+
345
+
346
+ def _get_free_port() -> int:
347
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
348
+ s.bind(("127.0.0.1", 0))
349
+ return s.getsockname()[1]
kaizen/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import signal
5
+ import sys
6
+
7
+ from kaizen import __version__
8
+ from kaizen.agent import OpenCodeAgent
9
+ from kaizen.config import load_config
10
+ from kaizen.git import is_git_repo
11
+
12
+
13
+ def cmd_loop(args: argparse.Namespace) -> None:
14
+ cwd = args.directory or args.cwd
15
+ if not is_git_repo(cwd):
16
+ print("Error: not a git repo", file=sys.stderr)
17
+ sys.exit(1)
18
+
19
+ if not args.prompt:
20
+ print("Error: prompt required", file=sys.stderr)
21
+ sys.exit(1)
22
+
23
+ config = load_config()
24
+ opencode_bin = args.opencode_bin or config.get("opencode_bin", "opencode")
25
+ use_worktree = not args.no_worktree
26
+ server_url = args.server_url or config.get("server_url")
27
+
28
+ agent = OpenCodeAgent(bin_path=opencode_bin, server_url=server_url)
29
+
30
+ force_exit = False
31
+
32
+ def handle_sigint(sig, frame):
33
+ nonlocal force_exit
34
+ if force_exit:
35
+ print("\n force stop")
36
+ agent.close()
37
+ sys.exit(130)
38
+ force_exit = True
39
+ print("\n stopping... (Ctrl+C again to force)")
40
+
41
+ signal.signal(signal.SIGINT, handle_sigint)
42
+ signal.signal(signal.SIGTERM, lambda s, f: sys.exit(0))
43
+
44
+ try:
45
+ from kaizen.loop import run_loop
46
+
47
+ result = run_loop(
48
+ prompt=args.prompt,
49
+ cwd=cwd,
50
+ agent=agent,
51
+ max_work_iterations=args.max_iterations,
52
+ max_review_rounds=args.max_review_rounds or config.get("max_review_rounds", 3),
53
+ use_worktree=use_worktree,
54
+ )
55
+
56
+ if result == "passed":
57
+ print("\n kaizen loop passed")
58
+ elif result == "cancelled":
59
+ print("\n kaizen loop cancelled")
60
+ sys.exit(1)
61
+ else:
62
+ print("\n kaizen loop failed")
63
+ sys.exit(1)
64
+ except Exception as e:
65
+ print(f"\n fatal: {e}", file=sys.stderr)
66
+ sys.exit(1)
67
+ finally:
68
+ agent.close()
69
+
70
+
71
+ def main() -> None:
72
+ parser = argparse.ArgumentParser(
73
+ prog="kaizen",
74
+ description="Continuous code improvement: work → review → fix → ship",
75
+ )
76
+ parser.add_argument("--version", action="version", version=f"kaizen {__version__}")
77
+ parser.add_argument("--directory", "-C", help="Path to git repo (default: current dir)")
78
+
79
+ parser.add_argument("prompt", nargs="?", help="What the agent should do")
80
+ parser.add_argument("--max-iterations", type=int, help="Max work iterations")
81
+ parser.add_argument("--max-review-rounds", type=int, help="Max review rounds")
82
+ parser.add_argument("--no-worktree", action="store_true", help="Work in current tree instead of worktree")
83
+ parser.add_argument("--opencode-bin", help="Path to opencode binary")
84
+ parser.add_argument("--server-url", help="URL of an existing opencode serve instance (e.g. http://127.0.0.1:4096)")
85
+
86
+ args = parser.parse_args()
87
+ args.cwd = args.directory or ""
88
+
89
+ if args.prompt:
90
+ cmd_loop(args)
91
+ else:
92
+ parser.print_help()
kaizen/config.py ADDED
@@ -0,0 +1,31 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ DEFAULT_CONFIG = {
7
+ "max_work_iterations": None,
8
+ "max_review_rounds": 3,
9
+ "max_consecutive_failures": 3,
10
+ "opencode_bin": "opencode",
11
+ "use_worktree": True,
12
+ "server_url": None,
13
+ }
14
+
15
+
16
+ def config_path() -> str:
17
+ return os.path.expanduser("~/.kaizen/config.json")
18
+
19
+
20
+ def load_config() -> dict:
21
+ path = config_path()
22
+ if os.path.exists(path):
23
+ try:
24
+ with open(path) as f:
25
+ cfg = json.load(f)
26
+ return {**DEFAULT_CONFIG, **cfg}
27
+ except (json.JSONDecodeError, OSError):
28
+ pass
29
+ os.makedirs(os.path.dirname(path), exist_ok=True)
30
+ Path(path).write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n")
31
+ return {**DEFAULT_CONFIG}
kaizen/findings.py ADDED
@@ -0,0 +1,53 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ ACTION_NOOP = "no-op"
4
+ ACTION_AUTO_FIX = "auto-fix"
5
+
6
+ SEVERITY_INFO = "info"
7
+ SEVERITY_WARNING = "warning"
8
+ SEVERITY_ERROR = "error"
9
+
10
+
11
+ @dataclass
12
+ class Finding:
13
+ id: str
14
+ severity: str
15
+ file: str = ""
16
+ line: int = 0
17
+ description: str = ""
18
+ action: str = ACTION_NOOP
19
+
20
+
21
+ @dataclass
22
+ class FindingsResult:
23
+ items: list[Finding] = field(default_factory=list)
24
+ summary: str = ""
25
+ risk_level: str = "low"
26
+ risk_rationale: str = ""
27
+
28
+ @property
29
+ def has_auto_fix(self) -> bool:
30
+ return any(f.action == ACTION_AUTO_FIX for f in self.items)
31
+
32
+ @property
33
+ def auto_fix_items(self) -> list[Finding]:
34
+ return [f for f in self.items if f.action == ACTION_AUTO_FIX]
35
+
36
+
37
+ def parse_findings(data: dict) -> FindingsResult:
38
+ items = []
39
+ for i, f in enumerate(data.get("findings", [])):
40
+ items.append(Finding(
41
+ id=f.get("id", f"f{i + 1}"),
42
+ severity=f.get("severity", SEVERITY_INFO),
43
+ file=f.get("file", ""),
44
+ line=f.get("line", 0),
45
+ description=f.get("description", ""),
46
+ action=f.get("action", ACTION_NOOP),
47
+ ))
48
+ return FindingsResult(
49
+ items=items,
50
+ summary=data.get("summary", ""),
51
+ risk_level=data.get("risk_level", "low"),
52
+ risk_rationale=data.get("risk_rationale", ""),
53
+ )