ai-code-engineer 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.
Files changed (44) hide show
  1. ai_code_engineer/__init__.py +2 -0
  2. ai_code_engineer/catalog.py +143 -0
  3. ai_code_engineer/chat.py +181 -0
  4. ai_code_engineer/cli.py +384 -0
  5. ai_code_engineer/config.py +405 -0
  6. ai_code_engineer/engine.py +1282 -0
  7. ai_code_engineer/errors.py +27 -0
  8. ai_code_engineer/git_integration.py +443 -0
  9. ai_code_engineer/gui.py +2646 -0
  10. ai_code_engineer/host.py +81 -0
  11. ai_code_engineer/ignore.py +269 -0
  12. ai_code_engineer/intent.py +222 -0
  13. ai_code_engineer/labels.py +871 -0
  14. ai_code_engineer/memory.py +91 -0
  15. ai_code_engineer/modes.py +156 -0
  16. ai_code_engineer/overrides.py +540 -0
  17. ai_code_engineer/planbook.py +192 -0
  18. ai_code_engineer/providers.py +404 -0
  19. ai_code_engineer/redaction.py +54 -0
  20. ai_code_engineer/repair.py +564 -0
  21. ai_code_engineer/report.py +352 -0
  22. ai_code_engineer/runner.py +854 -0
  23. ai_code_engineer/setup.py +386 -0
  24. ai_code_engineer/symbols.py +1286 -0
  25. ai_code_engineer/verification.py +218 -0
  26. ai_code_engineer/webapp/__init__.py +1 -0
  27. ai_code_engineer/webapp/__main__.py +45 -0
  28. ai_code_engineer/webapp/contract.py +36 -0
  29. ai_code_engineer/webapp/controller.py +3556 -0
  30. ai_code_engineer/webapp/fake.py +1141 -0
  31. ai_code_engineer/webapp/launch.py +108 -0
  32. ai_code_engineer/webapp/server.py +349 -0
  33. ai_code_engineer/webapp/static/app.css +780 -0
  34. ai_code_engineer/webapp/static/app.js +2118 -0
  35. ai_code_engineer/webapp/static/boot.js +19 -0
  36. ai_code_engineer/webapp/static/index.html +89 -0
  37. ai_code_engineer/webapp/static/tokens.css +173 -0
  38. ai_code_engineer/workspace.py +385 -0
  39. ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
  40. ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
  41. ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
  42. ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
  43. ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,108 @@
1
+ """Start the local server and show it as an app window instead of a browser tab.
2
+
3
+ Edge and Chrome both take ``--app=<url>``, which opens a frameless top-level window with
4
+ no address bar, tabs or extensions. Pointing them at a private ``--user-data-dir`` is what
5
+ makes that work: an already-running browser would otherwise hand the URL to its own
6
+ window and exit immediately, so the app would close the moment it opened.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import webbrowser
14
+ from pathlib import Path
15
+
16
+ MIN_WIDTH, MIN_HEIGHT = 1180, 720
17
+
18
+
19
+ def find_browser() -> Path | None:
20
+ """A Chromium browser that supports --app, or None when the machine has only Firefox."""
21
+ roots = [os.environ.get(key) for key in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA")]
22
+ candidates = [Path(root) / tail for root in roots if root
23
+ for tail in ("Microsoft/Edge/Application/msedge.exe",
24
+ "Google/Chrome/Application/chrome.exe",
25
+ "Chromium/Application/chrome.exe")]
26
+ candidates += [Path(rf"{drive}\Program Files\{vendor}\{exe}")
27
+ for drive in "CE" for vendor, exe in
28
+ (("Microsoft/Edge/Application", "msedge.exe"), ("Google/Chrome/Application", "chrome.exe"))]
29
+ return next((path for path in candidates if path.is_file()), None)
30
+
31
+
32
+ def window_size() -> tuple[int, int]:
33
+ """Fit the work area without a scrollbar; Tk is only used to measure the screen."""
34
+ try:
35
+ import tkinter
36
+ probe = tkinter.Tk()
37
+ probe.withdraw()
38
+ width, height = probe.winfo_screenwidth() - 120, probe.winfo_screenheight() - 140
39
+ probe.destroy()
40
+ except Exception: # noqa: BLE001 - headless is a valid answer too
41
+ width, height = 1440, 900
42
+ return max(MIN_WIDTH, min(width, 1800)), max(MIN_HEIGHT, min(height, 1200))
43
+
44
+
45
+ def open_window(url: str, profile: Path) -> subprocess.Popen | None:
46
+ browser = find_browser()
47
+ if browser is None:
48
+ return None
49
+ profile.mkdir(parents=True, exist_ok=True)
50
+ arguments = [str(browser), f"--app={url}", f"--user-data-dir={profile}",
51
+ "--no-first-run", "--no-default-browser-check",
52
+ "--disable-features=Translate,MediaRouter"]
53
+ if len(list(profile.glob("Preferences"))) == 0:
54
+ # A fresh profile has no remembered bounds, so give it sensible ones. Once the
55
+ # profile exists the browser restores its own size and place, and forcing these
56
+ # flags would throw away wherever the user moved the window.
57
+ width, height = window_size()
58
+ arguments += [f"--window-size={width},{height}", "--window-position=60,50"]
59
+ return subprocess.Popen(arguments,
60
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
61
+
62
+
63
+ def run(app_dir: Path) -> int:
64
+ """Serve the UI and block until its window closes.
65
+
66
+ Without a Chromium browser this still serves — in an ordinary tab, with the process held
67
+ open around it. The Tk window is not a fallback from here: it is `--tk`, or whatever
68
+ `desktop.pyw` starts when this function raises.
69
+ """
70
+ from .controller import AgentController
71
+ from .server import serve
72
+
73
+ controller = AgentController(app_dir)
74
+ server, url, _token = serve(controller)
75
+ # The model list is what the window used to fetch a moment after it appeared: an
76
+ # offline Ollama is a normal state, so it must not block the window from opening.
77
+ controller.check_setup()
78
+ window = open_window(url, app_dir / ".agent-webview")
79
+ if window is None:
80
+ # No Chromium on this machine: the page is still a real UI, just in a normal tab.
81
+ webbrowser.open(url)
82
+ try:
83
+ if window is not None:
84
+ window.wait()
85
+ else:
86
+ import time
87
+ while True:
88
+ time.sleep(3600)
89
+ except KeyboardInterrupt:
90
+ pass
91
+ finally:
92
+ controller.close()
93
+ server.shutdown()
94
+ return 0
95
+
96
+
97
+ def run_tk(app_dir: Path) -> int:
98
+ from ..gui import main as tk_main
99
+ tk_main(app_dir)
100
+ return 0
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ argv = list(sys.argv[1:] if argv is None else argv)
105
+ app_dir = Path(__file__).resolve().parents[3]
106
+ if "--tk" in argv:
107
+ return run_tk(app_dir)
108
+ return run(app_dir)
@@ -0,0 +1,349 @@
1
+ """Local-only HTTP server: static UI, a JSON action endpoint, and one SSE stream.
2
+
3
+ Binds to 127.0.0.1 on a random port and requires a per-launch token, because a browser
4
+ page on the same machine can otherwise be reached by any other process or web page.
5
+
6
+ The token alone was not enough. A page on any origin can *send* to a loopback port, and DNS
7
+ rebinding turns a hostile hostname into 127.0.0.1 inside the victim's browser — so every request is
8
+ also checked here for the Host it asked for and the Origin it came from, and every response carries a
9
+ Content-Security-Policy. Nothing in this module writes to a project.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import queue
15
+ import secrets
16
+ import threading
17
+ import urllib.parse
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from pathlib import Path
20
+
21
+ from ..errors import AgentError
22
+ from ..labels import friendly_error
23
+ from ..redaction import redact
24
+
25
+ STATIC = Path(__file__).resolve().parent / "static"
26
+ MAX_BODY = 1_000_000
27
+ DRAIN_CHUNK = 65_536
28
+ LOOPBACK = frozenset(("127.0.0.1", "localhost", "::1"))
29
+
30
+ # `script-src 'self'` with no 'unsafe-inline' is the line that matters, and index.html's boot script
31
+ # moved to its own file for it. Inline *styles* stay allowed: eight template strings set layout
32
+ # values inline (progress-bar widths, avatar colours), and a style cannot execute script.
33
+ CSP = ("default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
34
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; "
35
+ "frame-ancestors 'none'; base-uri 'none'; form-action 'none'; object-src 'none'")
36
+ SAFE_HEADERS = (
37
+ ("content-security-policy", CSP),
38
+ ("x-frame-options", "DENY"),
39
+ ("x-content-type-options", "nosniff"),
40
+ # The launch URL carries the session token in its query string; a referrer would be told it.
41
+ ("referrer-policy", "no-referrer"),
42
+ ("cross-origin-opener-policy", "same-origin"),
43
+ )
44
+
45
+
46
+ class Hub:
47
+ """Fan-out for the SSE stream; a slow client drops events instead of blocking the agent."""
48
+
49
+ def __init__(self) -> None:
50
+ self._clients: list[queue.Queue] = []
51
+ self._lock = threading.Lock()
52
+
53
+ def subscribe(self) -> queue.Queue:
54
+ client: queue.Queue = queue.Queue(maxsize=256)
55
+ with self._lock:
56
+ self._clients.append(client)
57
+ return client
58
+
59
+ def unsubscribe(self, client: queue.Queue) -> None:
60
+ with self._lock:
61
+ if client in self._clients:
62
+ self._clients.remove(client)
63
+
64
+ def publish(self, event: dict) -> None:
65
+ with self._lock:
66
+ clients = list(self._clients)
67
+ for client in clients:
68
+ try:
69
+ client.put_nowait(event)
70
+ except queue.Full:
71
+ pass
72
+
73
+
74
+ class Handler(BaseHTTPRequestHandler):
75
+ protocol_version = "HTTP/1.1"
76
+ server_version = "AICodeEngineer/1.0"
77
+
78
+ # Placeholders. The real four are set on a subclass per launch — see serve().
79
+ token: str = ""
80
+ controller = None
81
+ hub: Hub = Hub()
82
+ port: int = 0
83
+
84
+ def log_message(self, *_args): # silence per-request stderr noise
85
+ pass
86
+
87
+ # ---------------- plumbing ----------------
88
+ def _guard(self) -> tuple[str, int] | None:
89
+ """Refuse a request that is not addressed to this server, before reading its body.
90
+
91
+ A browser will happily send a POST to 127.0.0.1 from a page on another site — the token is
92
+ what stops it being *answered*. `Host` is the other half: DNS rebinding makes a hostile
93
+ hostname resolve to loopback inside the victim's own browser, and then the page is
94
+ same-origin with this server and can read the answer. Checking that the request was aimed at
95
+ a loopback name on our own port closes that, and checking `Origin` refuses the page that only
96
+ wanted to write.
97
+
98
+ Returns the sentence and the status, or None when the request is addressed here. Each route
99
+ answers in its own way, because a POST still has its body in the socket.
100
+ """
101
+ if not self._host_allowed(self.headers.get("host", "")):
102
+ return "This server answers only loopback requests addressed to itself.", 425
103
+ origin = self.headers.get("origin") or self.headers.get("referer") or ""
104
+ if origin and not self._origin_allowed(origin):
105
+ return "That request came from another origin.", 403
106
+ return None
107
+
108
+ def _origin_allowed(self, value: str) -> bool:
109
+ """Where the page that asked lives — a stricter question than the one `Host` answers.
110
+
111
+ A client may send `Host: localhost` with no port, so the port check above has to allow a
112
+ missing port. An `Origin` never does: a browser always states one, and it always carries a
113
+ scheme. So `https://localhost` or `http://localhost` is a different page from this one —
114
+ this server is plain http on one random port, and a request from anywhere else is a page
115
+ that wants to write into the session while not being the page that holds it.
116
+ """
117
+ try:
118
+ parsed = urllib.parse.urlparse(value)
119
+ except ValueError:
120
+ return False
121
+ if parsed.scheme != "http":
122
+ return False
123
+ if parsed.hostname not in LOOPBACK:
124
+ return False
125
+ return parsed.port == self.port
126
+
127
+ def _host_allowed(self, authority: str) -> bool:
128
+ """A loopback name, on this port — no rebinding, no other service wearing our token."""
129
+ if not authority:
130
+ return False
131
+ try:
132
+ parsed = urllib.parse.urlparse("//" + authority)
133
+ except ValueError:
134
+ return False
135
+ if parsed.hostname not in LOOPBACK:
136
+ return False
137
+ return parsed.port in (None, self.port)
138
+
139
+ def _safe(self) -> None:
140
+ """The headers every response carries, so a new route cannot forget them."""
141
+ for name, value in SAFE_HEADERS:
142
+ self.send_header(name, value)
143
+
144
+ def _authorized(self, query: dict) -> bool:
145
+ if not self.token:
146
+ # serve() has not handed this handler a token. Two empty strings are equal, so with no
147
+ # guard an unconfigured server would let every unauthenticated request through.
148
+ return False
149
+ supplied = query.get("t", [""])[0] or self.headers.get("X-Auth-Token", "")
150
+ return secrets.compare_digest(supplied, self.token)
151
+
152
+ def _json(self, value, code=200):
153
+ body = json.dumps(value, ensure_ascii=False).encode("utf-8")
154
+ self.send_response(code)
155
+ self._safe()
156
+ self.send_header("content-type", "application/json; charset=utf-8")
157
+ self.send_header("content-length", str(len(body)))
158
+ self.send_header("cache-control", "no-store")
159
+ self.end_headers()
160
+ self.wfile.write(body)
161
+
162
+ def _text(self, text, code=400):
163
+ body = text.encode("utf-8")
164
+ self.send_response(code)
165
+ self._safe()
166
+ self.send_header("content-type", "text/plain; charset=utf-8")
167
+ self.send_header("content-length", str(len(body)))
168
+ self.end_headers()
169
+ self.wfile.write(body)
170
+
171
+ def _fail(self, exc: Exception, code: int = 500):
172
+ """The answer for a request that went wrong: never the exception text as it stands.
173
+
174
+ An `AgentError` is a sentence this program wrote on purpose, so it is repeated — redacted and
175
+ capped, because some of them interpolate a provider's message or a folder path. Anything else
176
+ is somebody else's failure, and the browser gets one fixed line with a reference. The detail
177
+ goes to the Activity log, which is where a developer is looking anyway.
178
+ """
179
+ reference = secrets.token_hex(3)
180
+ record = getattr(self.controller, "note_failure", None)
181
+ if record is not None:
182
+ record(reference, redact(f"{type(exc).__name__}: {exc}")[:600])
183
+ if isinstance(exc, AgentError):
184
+ return self._text(redact(friendly_error(exc))[:400], code)
185
+ return self._text(f"The window could not complete that request (id {reference}). "
186
+ "The reason is in the Activity log.", code)
187
+
188
+ def _refuse(self, text, code):
189
+ """Answer a rejected POST without leaving its body in the socket.
190
+
191
+ A client that is still writing when the answer arrives gets its connection reset under
192
+ Windows, which turns a clean 403 into a broken pipe on the caller's side.
193
+ """
194
+ self._drain(int(self.headers.get("content-length") or 0))
195
+ # Any part of an oversized body we did not read is not a request, so this connection ends
196
+ # after the answer instead of being parsed as one.
197
+ self.close_connection = True
198
+ return self._text(text, code)
199
+
200
+ def _query(self) -> dict:
201
+ return urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
202
+
203
+ def _drain(self, length: int) -> None:
204
+ """Read a rejected body out of the socket so the client can finish its send.
205
+
206
+ Everything declared has to be consumed, or Windows aborts the connection under a client
207
+ that is still writing. The ceiling keeps a hostile content-length from turning that into
208
+ an unbounded read.
209
+ """
210
+ remaining = min(length, MAX_BODY + 8 * DRAIN_CHUNK)
211
+ while remaining > 0:
212
+ block = self.rfile.read(min(DRAIN_CHUNK, remaining))
213
+ if not block:
214
+ break
215
+ remaining -= len(block)
216
+
217
+ def _body(self) -> dict:
218
+ length = int(self.headers.get("content-length") or 0)
219
+ if length > MAX_BODY:
220
+ # Answering 400 while a megabyte is still in flight makes Windows abort the socket
221
+ # from under the client, which is what made the oversized-body test flaky.
222
+ self._drain(length)
223
+ raise ValueError("Request body too large.")
224
+ raw = self.rfile.read(length) if length else b"{}"
225
+ try:
226
+ value = json.loads(raw.decode("utf-8") or "{}")
227
+ except ValueError:
228
+ raise ValueError("Body must be JSON.") from None
229
+ return value if isinstance(value, dict) else {}
230
+
231
+ # ---------------- routes ----------------
232
+ def do_GET(self): # noqa: N802
233
+ refused = self._guard()
234
+ if refused:
235
+ return self._text(*refused)
236
+ path = urllib.parse.urlparse(self.path).path
237
+ query = self._query()
238
+ if not path.startswith("/api/"):
239
+ # Bundled assets carry no user data, and a browser never sends a query string
240
+ # for a stylesheet link.
241
+ return self._static(path)
242
+ if not self._authorized(query):
243
+ return self._text("Not authorised for this UI session.", 403)
244
+ return self._get_api(path, query)
245
+
246
+ def do_POST(self): # noqa: N802
247
+ refused = self._guard()
248
+ if refused:
249
+ return self._refuse(*refused)
250
+ path = urllib.parse.urlparse(self.path).path
251
+ if not self._authorized(self._query()):
252
+ return self._refuse("Not authorised for this UI session.", 403)
253
+ if path not in {"/api/action", "/api/confirm"}:
254
+ return self._refuse("Unknown endpoint.", 404)
255
+ try:
256
+ body = self._body()
257
+ except ValueError as exc:
258
+ # Both texts this can raise are written in _body() — "Body must be JSON." and "Request
259
+ # body too large." — so repeating one says something useful and leaks nothing.
260
+ return self._text(str(exc), 400)
261
+ try:
262
+ if path == "/api/confirm":
263
+ self.controller.set_reply(str(body.get("id", "")), body)
264
+ return self._json({"ok": True})
265
+ result = self.controller.action(str(body.get("type", "")), body, self.hub.publish)
266
+ return self._json({"ok": True, "result": result, "state": self.controller.snapshot()})
267
+ except Exception as exc: # a UI must never take the server down
268
+ return self._fail(exc)
269
+
270
+ def _get_api(self, path: str, query: dict):
271
+ if path == "/api/bootstrap":
272
+ return self._json(self.controller.snapshot())
273
+ if path == "/api/fs":
274
+ try:
275
+ return self._json(self.controller.list_dir(query.get("path", [""])[0]))
276
+ except (AgentError, OSError, ValueError) as exc:
277
+ return self._fail(exc, 400)
278
+ if path == "/api/project":
279
+ # The drawer walks the folder to measure it, so it is asked for on open rather
280
+ # than carried in every snapshot.
281
+ try:
282
+ return self._json(self.controller.project_info(query.get("project", [""])[0]))
283
+ except (AgentError, OSError, ValueError) as exc:
284
+ return self._fail(exc, 400)
285
+ if path == "/api/events":
286
+ return self._stream()
287
+ return self._text("Unknown endpoint.", 404)
288
+
289
+ def _stream(self):
290
+ client = self.hub.subscribe()
291
+ self.send_response(200)
292
+ self._safe()
293
+ self.send_header("content-type", "text/event-stream; charset=utf-8")
294
+ self.send_header("cache-control", "no-store")
295
+ self.send_header("x-accel-buffering", "no")
296
+ self.end_headers()
297
+ try:
298
+ self.wfile.write(b"retry: 1500\n\n")
299
+ self.wfile.flush()
300
+ while True:
301
+ try:
302
+ event = client.get(timeout=15)
303
+ chunk = f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode("utf-8")
304
+ except queue.Empty:
305
+ chunk = b": keep-alive\n\n"
306
+ self.wfile.write(chunk)
307
+ self.wfile.flush()
308
+ except (BrokenPipeError, ConnectionResetError, OSError):
309
+ pass
310
+ finally:
311
+ self.hub.unsubscribe(client)
312
+
313
+ def _static(self, path: str):
314
+ name = "index.html" if path in {"/", ""} else path.lstrip("/")
315
+ target = (STATIC / name).resolve()
316
+ if not target.is_file() or not target.is_relative_to(STATIC):
317
+ return self._text("Not found.", 404)
318
+ kinds = {".html": "text/html", ".css": "text/css", ".js": "text/javascript",
319
+ ".svg": "image/svg+xml", ".png": "image/png", ".woff2": "font/woff2", ".json": "application/json"}
320
+ body = target.read_bytes()
321
+ self.send_response(200)
322
+ self._safe()
323
+ self.send_header("content-type", kinds.get(target.suffix, "application/octet-stream") + "; charset=utf-8")
324
+ self.send_header("content-length", str(len(body)))
325
+ self.send_header("cache-control", "no-cache")
326
+ self.end_headers()
327
+ self.wfile.write(body)
328
+
329
+
330
+ def serve(controller, host: str = "127.0.0.1", port: int = 0):
331
+ """Start serving and return (server, url, token). Call server.shutdown() to stop."""
332
+ if host not in LOOPBACK:
333
+ raise AgentError("The UI binds to loopback only.")
334
+ token = secrets.token_urlsafe(16)
335
+ # One handler *class* per launch. `Handler` carries its session on the class because
336
+ # `BaseHTTPRequestHandler` is instantiated by the socket server with no room for constructor
337
+ # arguments, and writing the session on the shared class instead meant a second `serve()` in the
338
+ # same process took over the first one's authentication: the first window would answer the
339
+ # second's token and drive the second's controller. A subclass per server keeps two windows in
340
+ # one process — which the desktop launcher can be asked for — apart in all four fields.
341
+ bound = type("SessionHandler", (Handler,), {"token": token, "controller": controller, "hub": Hub()})
342
+ server = ThreadingHTTPServer((host, port), bound)
343
+ server.daemon_threads = True
344
+ actual = server.server_address[1]
345
+ # The Host check compares against the port this process actually owns, which is only knowable
346
+ # after binding: a caller that asked for port 0 gets a random one.
347
+ bound.port = actual
348
+ threading.Thread(target=server.serve_forever, name="ui-http", daemon=True).start()
349
+ return server, f"http://{host}:{actual}/?t={token}", token