axiometa-cli 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: axiometa-cli
3
+ Version: 1.0.0
4
+ Summary: Prompt to hardware, in your terminal: detect a board, chat to build, deploy, watch serial.
5
+ Author-email: Axiometa <dr.dumcius@gmail.com>
6
+ Project-URL: Homepage, https://axiometa.io
7
+ Project-URL: Studio, https://studio.axiometa.io
8
+ Keywords: hardware,ai,esp32,rp2040,raspberry-pi,arduino,micropython,iot,cli,maker
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: System :: Hardware
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: pyserial>=3.5
20
+ Provides-Extra: micropython
21
+ Requires-Dist: mpremote>=1.20; extra == "micropython"
22
+
23
+ # axiometa-cli
24
+
25
+ Prompt → hardware, in your terminal. The CLI is a thin client of the same backend Studio's browser
26
+ talks to: **codegen stays in the cloud** (Claude + the catalog + your auth/limits), and only the
27
+ **local half** — provision the toolchain, deploy, read the serial channel — runs on the machine the
28
+ board is plugged into. Same brain as Studio; the terminal (or a Pi) is the body instead of the browser.
29
+
30
+ It is one experience whether the board speaks Arduino or MicroPython — the board's facts decide the
31
+ deploy verb (`deploy` = compile+flash on Arduino; `run`/`install` on MicroPython).
32
+
33
+ ## Install
34
+
35
+ **Raspberry Pi / Linux / macOS** (from a checkout):
36
+ ```
37
+ ./cli/install.sh
38
+ axiometa provision # one-time: arduino-cli + ESP32 core + libs (+ mpremote)
39
+ axiometa # detect a board and start building
40
+ ```
41
+
42
+ **Windows / any OS** (pip):
43
+ ```
44
+ pip install -e cli
45
+ axiometa provision
46
+ axiometa
47
+ ```
48
+
49
+ Config (env, all optional):
50
+ ```
51
+ AXIOMETA_BACKEND=https://studio.axiometa.io # or your local backend
52
+ AXIOMETA_TOKEN=... # your auth (carried to the backend)
53
+ AXIOMETA_KNOWLEDGE=/path/to/backend/knowledge/boards # auto-found next to a repo checkout
54
+ ```
55
+
56
+ ## The experience (`axiometa`)
57
+
58
+ Typing `axiometa` with no verb runs the guided loop:
59
+
60
+ 1. **Detect** — lists connected boards, naming the silicon from its USB identity (ESP32-S3 vs RP2040).
61
+ 2. **Target** — confirms the board + language, picks the port, writes `axiometa.json`.
62
+ 3. **Toolchain** — if `arduino-cli`/`mpremote` is missing, offers `axiometa provision`.
63
+ 4. **Chat** — describe what to build; Axie streams its reply, shows what it's reading, and writes the
64
+ project files. Refine in follow-ups ("make it blink faster").
65
+ 5. **Deploy** — when the device program is (re)generated, offers to flash it (Arduino) or
66
+ run/install it (MicroPython), streaming the toolchain output.
67
+ 6. **Monitor** — after a successful deploy, streams the serial channel so you see the device talk.
68
+
69
+ Mid-chat commands: `/flash` `/deploy` `/run` `/install` `/monitor` `/serve` `/board` `/help` `/exit`.
70
+
71
+ ## Verbs (scripting / power use)
72
+
73
+ ```
74
+ axiometa provision # install the local toolchain (one-time)
75
+ axiometa devices # list connected boards (with USB identity)
76
+ axiometa board pi-hat # set the target (writes axiometa.json)
77
+ axiometa prompt "blink the RGB LED on port 1 red" # one-shot: generate + write files
78
+ axiometa flash --firmware mp.uf2 # RP2040 over SWD: lay MicroPython on the chip (Pi HAT)
79
+ axiometa run # MicroPython: test in RAM (ephemeral)
80
+ axiometa install # MicroPython: persist to flash
81
+ axiometa deploy # Arduino: compile + flash
82
+ axiometa monitor # stream serial to the terminal
83
+ axiometa serve # serve browser/*.html on 127.0.0.1 (ssh -L hint when headless)
84
+ ```
85
+
86
+ ## The Pi HAT (RP2040 flashed over SWD, talked-to over UART)
87
+
88
+ The Pi HAT's RP2040 has **no USB** — the Raspberry Pi is both its programmer and its host, over the
89
+ 40-pin header. So the Pi HAT path differs from a USB board, and the CLI handles it:
90
+
91
+ - **`axiometa flash --firmware <mp.uf2>`** lays MicroPython onto the chip over **SWD** (OpenOCD
92
+ bit-banging Pi GPIO24=SWDIO / GPIO25=SWCLK / GPIO23=RUN). A `.uf2` is auto-converted to the `.bin`
93
+ OpenOCD needs; `.elf`/`.bin` also work. This is one-time, and the brick-recovery path.
94
+ - **`run` / `install` / `monitor`** talk over the Pi's UART at **`/dev/serial0`** (RP2040 GP0/GP1 ↔
95
+ Pi GPIO15/14), not USB. The CLI ships `board_pins.py` + `boot.py` with each deploy; `boot.py` puts
96
+ the REPL on UART0 so `mpremote` can reach the board over `/dev/serial0`.
97
+
98
+ One-time Pi setup (handled by `axiometa provision`, or do it yourself):
99
+ ```
100
+ sudo apt install openocd # SWD flashing
101
+ sudo raspi-config -> Serial Port: login shell over serial = NO, serial hardware = YES # frees /dev/serial0
102
+ sudo -E axiometa flash --firmware mp.uf2 # SWD needs GPIO/root; -E keeps your env
103
+ ```
104
+ Notes: the flashed firmware must expose the **UART REPL** (freeze `boot.py` into the build, or use a
105
+ UART-REPL MicroPython) for first contact. `bcm2835gpio` (the default OpenOCD driver) is Pi 0-4 only;
106
+ on a **Pi 5** set `AXIOMETA_OPENOCD_DRIVER=linuxgpiod`. Override the whole OpenOCD interface with
107
+ `AXIOMETA_OPENOCD_CFG=/path/to.cfg` if your wiring differs.
108
+
109
+ ## Where things live
110
+
111
+ A **project is a folder** with an `axiometa.json` manifest (board + substrate + modules), the device
112
+ program (`sketch.ino` / `main.py`), any `browser/*.html` surfaces, and `board_pins.py`. `cd` into a
113
+ folder and run `axiometa` — it is project-rooted, like `git`.
114
+
115
+ ## Headless vs. desktop
116
+
117
+ The CLI detects where a visual surface can render and tells the backend (the `capability_profile`):
118
+ on a Pi with a display it serves the surface to a local browser; headless-over-SSH it prints an
119
+ `ssh -L` line to open it from your own machine; a bare terminal degrades a surface to a serial view.
120
+
121
+ ## Status
122
+
123
+ The agentic loop is board-agnostic and built: detect → chat (SSE codegen) → flash/deploy → monitor,
124
+ plus a cross-platform `provision`. The Pi HAT's **SWD flash (OpenOCD)** and **UART (`/dev/serial0`)**
125
+ deploy/monitor path is wired and unit-tested (uf2→bin, config generation, dispatch) but **not yet
126
+ validated on real hardware** — exercise it on a connected Pi + RP2040. The one external dependency is
127
+ a MicroPython firmware that exposes the UART REPL.
128
+
129
+ The native **host-linux** runtime (the "whole Pi" brain: code using the host's own camera/files/GPIO)
130
+ is **not** wired here — it is approval-gated and waits on the trust model in
131
+ `backend/knowledge/runtimes/host-linux/contract.md`.
@@ -0,0 +1,109 @@
1
+ # axiometa-cli
2
+
3
+ Prompt → hardware, in your terminal. The CLI is a thin client of the same backend Studio's browser
4
+ talks to: **codegen stays in the cloud** (Claude + the catalog + your auth/limits), and only the
5
+ **local half** — provision the toolchain, deploy, read the serial channel — runs on the machine the
6
+ board is plugged into. Same brain as Studio; the terminal (or a Pi) is the body instead of the browser.
7
+
8
+ It is one experience whether the board speaks Arduino or MicroPython — the board's facts decide the
9
+ deploy verb (`deploy` = compile+flash on Arduino; `run`/`install` on MicroPython).
10
+
11
+ ## Install
12
+
13
+ **Raspberry Pi / Linux / macOS** (from a checkout):
14
+ ```
15
+ ./cli/install.sh
16
+ axiometa provision # one-time: arduino-cli + ESP32 core + libs (+ mpremote)
17
+ axiometa # detect a board and start building
18
+ ```
19
+
20
+ **Windows / any OS** (pip):
21
+ ```
22
+ pip install -e cli
23
+ axiometa provision
24
+ axiometa
25
+ ```
26
+
27
+ Config (env, all optional):
28
+ ```
29
+ AXIOMETA_BACKEND=https://studio.axiometa.io # or your local backend
30
+ AXIOMETA_TOKEN=... # your auth (carried to the backend)
31
+ AXIOMETA_KNOWLEDGE=/path/to/backend/knowledge/boards # auto-found next to a repo checkout
32
+ ```
33
+
34
+ ## The experience (`axiometa`)
35
+
36
+ Typing `axiometa` with no verb runs the guided loop:
37
+
38
+ 1. **Detect** — lists connected boards, naming the silicon from its USB identity (ESP32-S3 vs RP2040).
39
+ 2. **Target** — confirms the board + language, picks the port, writes `axiometa.json`.
40
+ 3. **Toolchain** — if `arduino-cli`/`mpremote` is missing, offers `axiometa provision`.
41
+ 4. **Chat** — describe what to build; Axie streams its reply, shows what it's reading, and writes the
42
+ project files. Refine in follow-ups ("make it blink faster").
43
+ 5. **Deploy** — when the device program is (re)generated, offers to flash it (Arduino) or
44
+ run/install it (MicroPython), streaming the toolchain output.
45
+ 6. **Monitor** — after a successful deploy, streams the serial channel so you see the device talk.
46
+
47
+ Mid-chat commands: `/flash` `/deploy` `/run` `/install` `/monitor` `/serve` `/board` `/help` `/exit`.
48
+
49
+ ## Verbs (scripting / power use)
50
+
51
+ ```
52
+ axiometa provision # install the local toolchain (one-time)
53
+ axiometa devices # list connected boards (with USB identity)
54
+ axiometa board pi-hat # set the target (writes axiometa.json)
55
+ axiometa prompt "blink the RGB LED on port 1 red" # one-shot: generate + write files
56
+ axiometa flash --firmware mp.uf2 # RP2040 over SWD: lay MicroPython on the chip (Pi HAT)
57
+ axiometa run # MicroPython: test in RAM (ephemeral)
58
+ axiometa install # MicroPython: persist to flash
59
+ axiometa deploy # Arduino: compile + flash
60
+ axiometa monitor # stream serial to the terminal
61
+ axiometa serve # serve browser/*.html on 127.0.0.1 (ssh -L hint when headless)
62
+ ```
63
+
64
+ ## The Pi HAT (RP2040 flashed over SWD, talked-to over UART)
65
+
66
+ The Pi HAT's RP2040 has **no USB** — the Raspberry Pi is both its programmer and its host, over the
67
+ 40-pin header. So the Pi HAT path differs from a USB board, and the CLI handles it:
68
+
69
+ - **`axiometa flash --firmware <mp.uf2>`** lays MicroPython onto the chip over **SWD** (OpenOCD
70
+ bit-banging Pi GPIO24=SWDIO / GPIO25=SWCLK / GPIO23=RUN). A `.uf2` is auto-converted to the `.bin`
71
+ OpenOCD needs; `.elf`/`.bin` also work. This is one-time, and the brick-recovery path.
72
+ - **`run` / `install` / `monitor`** talk over the Pi's UART at **`/dev/serial0`** (RP2040 GP0/GP1 ↔
73
+ Pi GPIO15/14), not USB. The CLI ships `board_pins.py` + `boot.py` with each deploy; `boot.py` puts
74
+ the REPL on UART0 so `mpremote` can reach the board over `/dev/serial0`.
75
+
76
+ One-time Pi setup (handled by `axiometa provision`, or do it yourself):
77
+ ```
78
+ sudo apt install openocd # SWD flashing
79
+ sudo raspi-config -> Serial Port: login shell over serial = NO, serial hardware = YES # frees /dev/serial0
80
+ sudo -E axiometa flash --firmware mp.uf2 # SWD needs GPIO/root; -E keeps your env
81
+ ```
82
+ Notes: the flashed firmware must expose the **UART REPL** (freeze `boot.py` into the build, or use a
83
+ UART-REPL MicroPython) for first contact. `bcm2835gpio` (the default OpenOCD driver) is Pi 0-4 only;
84
+ on a **Pi 5** set `AXIOMETA_OPENOCD_DRIVER=linuxgpiod`. Override the whole OpenOCD interface with
85
+ `AXIOMETA_OPENOCD_CFG=/path/to.cfg` if your wiring differs.
86
+
87
+ ## Where things live
88
+
89
+ A **project is a folder** with an `axiometa.json` manifest (board + substrate + modules), the device
90
+ program (`sketch.ino` / `main.py`), any `browser/*.html` surfaces, and `board_pins.py`. `cd` into a
91
+ folder and run `axiometa` — it is project-rooted, like `git`.
92
+
93
+ ## Headless vs. desktop
94
+
95
+ The CLI detects where a visual surface can render and tells the backend (the `capability_profile`):
96
+ on a Pi with a display it serves the surface to a local browser; headless-over-SSH it prints an
97
+ `ssh -L` line to open it from your own machine; a bare terminal degrades a surface to a serial view.
98
+
99
+ ## Status
100
+
101
+ The agentic loop is board-agnostic and built: detect → chat (SSE codegen) → flash/deploy → monitor,
102
+ plus a cross-platform `provision`. The Pi HAT's **SWD flash (OpenOCD)** and **UART (`/dev/serial0`)**
103
+ deploy/monitor path is wired and unit-tested (uf2→bin, config generation, dispatch) but **not yet
104
+ validated on real hardware** — exercise it on a connected Pi + RP2040. The one external dependency is
105
+ a MicroPython firmware that exposes the UART REPL.
106
+
107
+ The native **host-linux** runtime (the "whole Pi" brain: code using the host's own camera/files/GPIO)
108
+ is **not** wired here — it is approval-gated and waits on the trust model in
109
+ `backend/knowledge/runtimes/host-linux/contract.md`.
@@ -0,0 +1,19 @@
1
+ """axiometa-cli — the terminal client for Axiometa Studio.
2
+
3
+ The CLI is a thin client of the same backend Studio's browser talks to: codegen stays in the
4
+ cloud (Claude + prompt_builder + catalog), and only the DEVICE half (build/deploy + reading the
5
+ serial channel) happens locally, on the machine the board is plugged into (your laptop, or a Pi
6
+ over SSH). That mirrors Studio exactly: Studio's backend generates and the browser flashes; the
7
+ CLI's backend generates and the terminal flashes.
8
+
9
+ Three ideas the CLI implements, all grounded in the backend registries:
10
+ - The device program is a property of the (board, substrate): sketch.ino on Arduino, main.py on
11
+ MicroPython. The DEPLOY VERBS follow from board.json: an fqbn board has one verb (`deploy` =
12
+ compile+flash); an mpremote board has two (`run` = ephemeral RAM test, `install` = persist).
13
+ - The CLIENT ENVIRONMENT (where surfaces render, whether host code runs) is sent to the backend
14
+ as a capability profile {surface, host_exec} so Axie builds only reachable surfaces. The CLI
15
+ never enumerates peripherals — what is connected is discovered, never predicted.
16
+ - The serial CHANNEL is one contract; only its TRANSPORT changes: raw stdout in a terminal, or a
17
+ 127.0.0.1 + token-gated localhost page (opened locally, or reached over `ssh -L`) for a surface.
18
+ """
19
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,116 @@
1
+ """Auth: where the CLI keeps your token, and how `axiometa login` gets one.
2
+
3
+ The token sent to the backend is a Supabase JWT, the same auth Studio uses. Resolution order:
4
+ AXIOMETA_TOKEN env var (scripting / CI) > the token stored by `axiometa login`. So you sign in once
5
+ and it persists, instead of exporting an env var every session.
6
+
7
+ `axiometa login` tries a device-code browser flow against the backend (ask for a code, you approve it
8
+ in a signed-in browser, the CLI polls until granted). If the backend does not expose that endpoint
9
+ yet, it falls back to pasting a token. Either way the token is stored at ~/.config/axiometa/token
10
+ (0600). Guest mode (no token) still works, rate-limited.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from pathlib import Path
20
+
21
+
22
+ def _config_dir() -> Path:
23
+ base = os.environ.get("XDG_CONFIG_HOME")
24
+ if base:
25
+ return Path(base) / "axiometa"
26
+ if os.name == "nt":
27
+ return Path(os.environ.get("APPDATA", Path.home())) / "axiometa"
28
+ return Path.home() / ".config" / "axiometa"
29
+
30
+
31
+ _TOKEN_FILE = _config_dir() / "token"
32
+
33
+
34
+ # ── storage ──
35
+ def stored_token() -> str | None:
36
+ try:
37
+ t = _TOKEN_FILE.read_text(encoding="utf-8").strip()
38
+ return t or None
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def get_token() -> str | None:
44
+ """The token to authenticate with: env var first (scripting), then the stored login."""
45
+ return os.environ.get("AXIOMETA_TOKEN") or stored_token()
46
+
47
+
48
+ def save_token(token: str) -> Path:
49
+ _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
50
+ _TOKEN_FILE.write_text(token.strip() + "\n", encoding="utf-8")
51
+ try:
52
+ os.chmod(_TOKEN_FILE, 0o600)
53
+ except Exception:
54
+ pass
55
+ return _TOKEN_FILE
56
+
57
+
58
+ def clear_token() -> bool:
59
+ try:
60
+ _TOKEN_FILE.unlink()
61
+ return True
62
+ except FileNotFoundError:
63
+ return False
64
+ except Exception:
65
+ return False
66
+
67
+
68
+ def token_path() -> Path:
69
+ return _TOKEN_FILE
70
+
71
+
72
+ # ── device-code flow (CLI side; needs the backend's /api/cli/login endpoints) ──
73
+ def _post(url: str, payload: dict, timeout: int = 15) -> dict:
74
+ req = urllib.request.Request(url, data=json.dumps(payload).encode(),
75
+ headers={"Content-Type": "application/json"})
76
+ with urllib.request.urlopen(req, timeout=timeout) as r:
77
+ return json.loads(r.read().decode() or "{}")
78
+
79
+
80
+ def device_login_start(backend: str) -> dict | None:
81
+ """Begin a device-code login. Returns the start payload (user_code, verification_url, device_code,
82
+ interval, expires_in), or None if the backend has no device-code endpoint yet."""
83
+ try:
84
+ return _post(backend.rstrip("/") + "/api/cli/login/start", {})
85
+ except urllib.error.HTTPError as e:
86
+ if e.code in (404, 405, 501):
87
+ return None
88
+ raise
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def device_login_poll(backend: str, start: dict, on_wait=None) -> str | None:
94
+ """Poll until the user approves (returns the token) or it expires/denies (returns None)."""
95
+ backend = backend.rstrip("/")
96
+ device_code = start.get("device_code")
97
+ interval = max(2, int(start.get("interval", 3)))
98
+ deadline = time.time() + int(start.get("expires_in", 300))
99
+ while time.time() < deadline:
100
+ time.sleep(interval)
101
+ if on_wait:
102
+ on_wait()
103
+ try:
104
+ r = _post(backend + "/api/cli/login/poll", {"device_code": device_code})
105
+ except urllib.error.HTTPError as e:
106
+ if e.code in (400, 428): # authorization_pending — keep waiting
107
+ continue
108
+ return None
109
+ except Exception:
110
+ continue
111
+ if r.get("token"):
112
+ save_token(r["token"])
113
+ return r["token"]
114
+ if r.get("error") in ("expired_token", "access_denied", "expired"):
115
+ return None
116
+ return None
@@ -0,0 +1,137 @@
1
+ """Backend chat client — the SSE stream from POST /api/ai-chat.
2
+
3
+ Codegen lives in the cloud (Claude + the catalog + your auth/limits): the CLI sends the conversation
4
+ + the project VFS + the board + the client environment, and the backend streams back a Server-Sent
5
+ Events feed. This module speaks that exact contract (the event vocabulary the backend emits) and keeps
6
+ the multi-turn state (message history + the project files) so a follow-up like 'make it blink faster'
7
+ sees the current build.
8
+
9
+ SSE event types (from backend/app/main.py): round_start, tool_call, text, gen, tool_done,
10
+ file_write, file_edit, file_delete, context_trace, done, error. The CLI renders `text` live, shows
11
+ `tool_call` as activity, applies the file_* events to the VFS, and closes on `done` (whose `message`
12
+ is the full assistant turn, plus relay_url/device_key when a relay build was produced).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import urllib.request
18
+ from dataclasses import dataclass, field
19
+
20
+ MANIFEST = "axiometa.json" # canonical; backend also reads legacy genesis.json
21
+
22
+
23
+ @dataclass
24
+ class TurnResult:
25
+ text: str = "" # the assistant's full reply this turn
26
+ files_changed: list = field(default_factory=list) # paths written/edited this turn
27
+ files_deleted: list = field(default_factory=list)
28
+ error: str | None = None
29
+ relay_url: str | None = None
30
+ device_key: str | None = None
31
+
32
+
33
+ class ChatSession:
34
+ """One project conversation. Holds the VFS + history so each turn is stateful."""
35
+
36
+ def __init__(self, backend: str, token: str | None, board_id: str,
37
+ capability_profile: dict, files: dict | None = None,
38
+ file_meta: dict | None = None, connected_integrations: list | None = None):
39
+ self.backend = backend.rstrip("/")
40
+ self.token = token
41
+ self.board_id = board_id
42
+ self.capability_profile = capability_profile
43
+ self.files: dict = dict(files or {})
44
+ self.file_meta: dict = dict(file_meta or {})
45
+ self.connected_integrations = connected_integrations or []
46
+ self.messages: list = []
47
+
48
+ # ── transport ──
49
+ def _request(self, payload: dict):
50
+ headers = {"Content-Type": "application/json", "Accept": "text/event-stream"}
51
+ if self.token:
52
+ headers["Authorization"] = f"Bearer {self.token}"
53
+ req = urllib.request.Request(self.backend + "/api/ai-chat",
54
+ data=json.dumps(payload).encode(), headers=headers)
55
+ return urllib.request.urlopen(req, timeout=600)
56
+
57
+ def _payload(self, active_module_ids: list | None) -> dict:
58
+ return {
59
+ "messages": self.messages,
60
+ "board_id": self.board_id,
61
+ "files": self.files,
62
+ "file_meta": self.file_meta,
63
+ "capability_profile": self.capability_profile,
64
+ "connected_integrations": self.connected_integrations,
65
+ "active_module_ids": active_module_ids,
66
+ }
67
+
68
+ # ── one turn ──
69
+ def send(self, user_text: str, on_event=None, active_module_ids: list | None = None) -> TurnResult:
70
+ """Append the user message, stream the reply, apply file events, return a TurnResult.
71
+
72
+ on_event(evt: dict) is called for every SSE event so the caller can render live (text deltas,
73
+ tool activity, file writes). The VFS and message history are updated in place.
74
+ """
75
+ self.messages.append({"role": "user", "content": user_text})
76
+ res = TurnResult()
77
+ acc_text = []
78
+ try:
79
+ resp = self._request(self._payload(active_module_ids))
80
+ except Exception as e:
81
+ res.error = f"backend call failed: {e}"
82
+ return res
83
+
84
+ ctype = (resp.headers.get("Content-Type") or "").lower()
85
+ if "text/event-stream" not in ctype:
86
+ # Non-streaming fallback (e.g. a local/old backend returning plain JSON).
87
+ try:
88
+ data = json.loads(resp.read().decode())
89
+ except Exception as e:
90
+ res.error = f"unexpected backend response: {e}"
91
+ return res
92
+ for path, content in (data.get("files") or data.get("project_files") or {}).items():
93
+ self.files[path] = content
94
+ res.files_changed.append(path)
95
+ res.text = data.get("message") or data.get("reply") or ""
96
+ self.messages.append({"role": "assistant", "content": res.text})
97
+ return res
98
+
99
+ for raw in resp:
100
+ line = raw.decode("utf-8", "replace").strip()
101
+ if not line.startswith("data:"):
102
+ continue
103
+ try:
104
+ evt = json.loads(line[5:].strip())
105
+ except Exception:
106
+ continue
107
+ if on_event:
108
+ on_event(evt)
109
+ t = evt.get("type")
110
+ if t == "text":
111
+ acc_text.append(evt.get("delta", ""))
112
+ elif t == "file_write":
113
+ self.files[evt["path"]] = evt.get("content", "")
114
+ if evt.get("name") or evt.get("kind"):
115
+ self.file_meta[evt["path"]] = {"name": evt.get("name"), "kind": evt.get("kind")}
116
+ res.files_changed.append(evt["path"])
117
+ elif t == "file_edit":
118
+ if evt.get("content") is not None:
119
+ self.files[evt["path"]] = evt["content"]
120
+ res.files_changed.append(evt["path"])
121
+ elif t == "file_delete":
122
+ self.files.pop(evt["path"], None)
123
+ res.files_deleted.append(evt["path"])
124
+ elif t == "done":
125
+ res.text = evt.get("message") or "".join(acc_text)
126
+ res.relay_url = evt.get("relay_url")
127
+ res.device_key = evt.get("device_key")
128
+ elif t == "error":
129
+ res.error = evt.get("message", "unknown error")
130
+
131
+ if not res.text:
132
+ res.text = "".join(acc_text)
133
+ # Dedup changed paths, preserve order.
134
+ seen = set()
135
+ res.files_changed = [p for p in res.files_changed if not (p in seen or seen.add(p))]
136
+ self.messages.append({"role": "assistant", "content": res.text})
137
+ return res