scootcli 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 (71) hide show
  1. scootcli/__init__.py +4 -0
  2. scootcli/__main__.py +9 -0
  3. scootcli/activity.py +26 -0
  4. scootcli/agent.py +350 -0
  5. scootcli/approvals.py +167 -0
  6. scootcli/auth.py +59 -0
  7. scootcli/cli.py +276 -0
  8. scootcli/clipboard.py +89 -0
  9. scootcli/commands/__init__.py +56 -0
  10. scootcli/commands/approve.py +38 -0
  11. scootcli/commands/auth.py +107 -0
  12. scootcli/commands/base.py +31 -0
  13. scootcli/commands/compact.py +40 -0
  14. scootcli/commands/copy.py +23 -0
  15. scootcli/commands/exit.py +14 -0
  16. scootcli/commands/forget.py +28 -0
  17. scootcli/commands/help.py +29 -0
  18. scootcli/commands/init.py +50 -0
  19. scootcli/commands/logo.py +51 -0
  20. scootcli/commands/model.py +61 -0
  21. scootcli/commands/panel.py +28 -0
  22. scootcli/commands/reset.py +20 -0
  23. scootcli/commands/resume.py +31 -0
  24. scootcli/commands/save.py +29 -0
  25. scootcli/commands/sessions.py +42 -0
  26. scootcli/commands/status.py +59 -0
  27. scootcli/commands/verbosity.py +57 -0
  28. scootcli/commands/worktree.py +64 -0
  29. scootcli/commands/yolo.py +20 -0
  30. scootcli/config.py +241 -0
  31. scootcli/context.py +82 -0
  32. scootcli/credentials.py +79 -0
  33. scootcli/errors.py +87 -0
  34. scootcli/images.py +169 -0
  35. scootcli/keys.py +119 -0
  36. scootcli/lineeditor.py +577 -0
  37. scootcli/logo.py +116 -0
  38. scootcli/models.py +120 -0
  39. scootcli/panel.py +263 -0
  40. scootcli/preferences.py +87 -0
  41. scootcli/presets.py +38 -0
  42. scootcli/project.py +94 -0
  43. scootcli/prompts.py +100 -0
  44. scootcli/providers/__init__.py +20 -0
  45. scootcli/providers/base.py +370 -0
  46. scootcli/providers/openai_chat.py +142 -0
  47. scootcli/providers/openai_responses.py +248 -0
  48. scootcli/providers/registry.py +173 -0
  49. scootcli/rendering.py +86 -0
  50. scootcli/repl.py +801 -0
  51. scootcli/sessions.py +186 -0
  52. scootcli/status.py +71 -0
  53. scootcli/tools/__init__.py +68 -0
  54. scootcli/tools/base.py +152 -0
  55. scootcli/tools/edit_file.py +72 -0
  56. scootcli/tools/list_dir.py +47 -0
  57. scootcli/tools/read_file.py +56 -0
  58. scootcli/tools/run_shell.py +73 -0
  59. scootcli/tools/search.py +170 -0
  60. scootcli/tools/update_plan.py +104 -0
  61. scootcli/tools/write_file.py +61 -0
  62. scootcli/transport.py +312 -0
  63. scootcli/vision.py +167 -0
  64. scootcli/workspace.py +105 -0
  65. scootcli/worktree.py +114 -0
  66. scootcli-0.1.0.dist-info/METADATA +238 -0
  67. scootcli-0.1.0.dist-info/RECORD +71 -0
  68. scootcli-0.1.0.dist-info/WHEEL +5 -0
  69. scootcli-0.1.0.dist-info/entry_points.txt +2 -0
  70. scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. scootcli-0.1.0.dist-info/top_level.txt +1 -0
scootcli/transport.py ADDED
@@ -0,0 +1,312 @@
1
+ """HTTP transport for provider APIs: pure stdlib, interruptible, streaming-aware.
2
+
3
+ :class:`NativeTransport` speaks HTTP/1.1 over ``http.client``/``socket``/``ssl`` with no third-party
4
+ dependency and no ``curl``. Its contract is tiny: ``request`` returns ``(status, body)`` and
5
+ ``stream_request`` yields body lines (SSE) ending with a ``HTTP_STATUS:<code>`` sentinel. Both honour a
6
+ ``cancel_event`` and abort the instant ESC is pressed (the socket is closed from a watcher thread).
7
+
8
+ Connections are direct, or tunnelled through ``HTTPS_PROXY`` with ``CONNECT`` (no-auth or Basic);
9
+ localhost and ``NO_PROXY`` hosts always connect directly. Plain ``http://`` URLs (a local Ollama, for
10
+ instance) skip TLS.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import http.client
17
+ import json
18
+ import os
19
+ import socket
20
+ import ssl
21
+ import threading
22
+ import time
23
+ from typing import Iterator, List, Optional, Tuple
24
+ from urllib.parse import urlsplit
25
+
26
+ from .config import Config
27
+ from .errors import Interrupted, NetworkError, ProxyError, RequestTimeout, SSLError, TransportError
28
+
29
+ _STATUS_MARKER = "\nHTTP_STATUS:"
30
+
31
+ # Fail fast if we can't even connect to the proxy/host (seconds); separate from the overall/idle
32
+ # budget so a dead network is detected quickly while a slow-but-alive stream is allowed to continue.
33
+ _CONNECT_TIMEOUT = 30
34
+
35
+
36
+ def classify_native_error(exc: BaseException) -> TransportError:
37
+ """Map a socket/ssl/http error to a typed transport error with an actionable hint."""
38
+ if isinstance(exc, TransportError):
39
+ return exc
40
+ if isinstance(exc, ssl.SSLError):
41
+ return SSLError(str(exc) or "TLS error", hint="TLS handshake failed; check the endpoint URL and CA certificates")
42
+ if isinstance(exc, socket.timeout):
43
+ return RequestTimeout("request timed out", hint="slow or blocked network")
44
+ if isinstance(exc, socket.gaierror):
45
+ return NetworkError("cannot resolve host", hint="no internet, DNS failure, or a typo in the base URL")
46
+ if isinstance(exc, (ConnectionError, BrokenPipeError)):
47
+ return NetworkError(str(exc) or "connection failed", hint="is the endpoint up? check the base URL and proxy")
48
+ if isinstance(exc, OSError):
49
+ return NetworkError(str(exc) or "network error", hint="check network/proxy")
50
+ return TransportError(f"transport error: {exc}")
51
+
52
+
53
+ def _parse_proxy(proxy_url: Optional[str]) -> Optional[Tuple[str, int, Optional[str]]]:
54
+ """Return ``(host, port, basic_auth_userpass_or_None)`` for a proxy URL, or ``None`` if unset."""
55
+ if not proxy_url:
56
+ return None
57
+ parts = urlsplit(proxy_url if "://" in proxy_url else "http://" + proxy_url)
58
+ host = parts.hostname
59
+ if not host:
60
+ return None
61
+ port = parts.port or 8080
62
+ basic = None
63
+ if parts.username:
64
+ basic = f"{parts.username}:{parts.password or ''}"
65
+ return host, port, basic
66
+
67
+
68
+ def _split_url(url: str) -> Tuple[str, int, str]:
69
+ """Return ``(host, port, path_with_query)`` for an http(s) URL."""
70
+ parts = urlsplit(url)
71
+ host = parts.hostname or ""
72
+ port = parts.port or (443 if parts.scheme == "https" else 80)
73
+ path = parts.path or "/"
74
+ if parts.query:
75
+ path += "?" + parts.query
76
+ return host, port, path
77
+
78
+
79
+ def _is_tls(url: str) -> bool:
80
+ return urlsplit(url).scheme.lower() != "http"
81
+
82
+
83
+ _LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1", "0.0.0.0")
84
+
85
+
86
+ def proxy_bypassed(host: str, no_proxy: Optional[str] = None) -> bool:
87
+ """Local hosts and anything listed in ``NO_PROXY`` (comma-separated, ``*`` for all) skip the proxy."""
88
+ h = (host or "").lower()
89
+ if h in _LOCAL_HOSTS or h.endswith(".localhost"):
90
+ return True
91
+ raw = no_proxy if no_proxy is not None else (os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "")
92
+ for entry in raw.split(","):
93
+ entry = entry.strip().lower().lstrip(".")
94
+ if not entry:
95
+ continue
96
+ if entry == "*" or h == entry or h.endswith("." + entry):
97
+ return True
98
+ return False
99
+
100
+
101
+ class NativeTransport:
102
+ """Pure-stdlib HTTP transport with cooperative cancellation."""
103
+
104
+ def __init__(self, config: Config):
105
+ self.config = config
106
+
107
+ # ── public API ───────────────────────────────────────────────────────────────
108
+ def request(self, method, url, auth_token, auth_scheme="Bearer", body=None,
109
+ extra_headers=None, cancel_event=None) -> "tuple[int, str]":
110
+ holder: dict = {}
111
+ stop = threading.Event()
112
+ self._start_watch(holder, cancel_event, stop)
113
+ try:
114
+ resp = self._connect_and_request(
115
+ method, url, auth_token, auth_scheme, body, extra_headers, holder)
116
+ data = resp.read()
117
+ return resp.status, data.decode("utf-8", "replace").strip()
118
+ except Interrupted:
119
+ raise
120
+ except BaseException as exc: # noqa: BLE001 - normalise to typed errors
121
+ if cancel_event is not None and cancel_event.is_set():
122
+ raise Interrupted("request cancelled by user")
123
+ raise classify_native_error(exc)
124
+ finally:
125
+ stop.set()
126
+ self._close(holder)
127
+
128
+ def stream_request(self, method, url, auth_token, auth_scheme="Bearer", body=None,
129
+ extra_headers=None, cancel_event=None) -> Iterator[str]:
130
+ holder: dict = {}
131
+ stop = threading.Event()
132
+ self._start_watch(holder, cancel_event, stop)
133
+ try:
134
+ resp = self._connect_and_request(
135
+ method, url, auth_token, auth_scheme, body, extra_headers, holder)
136
+ yield from self._stream_lines(resp, resp.status, cancel_event)
137
+ except Interrupted:
138
+ raise
139
+ except GeneratorExit:
140
+ raise
141
+ except BaseException as exc: # noqa: BLE001
142
+ if cancel_event is not None and cancel_event.is_set():
143
+ raise Interrupted("request cancelled by user")
144
+ raise classify_native_error(exc)
145
+ finally:
146
+ stop.set()
147
+ self._close(holder)
148
+
149
+ # ── request/response plumbing ────────────────────────────────────────────────
150
+ def _connect_and_request(self, method, url, auth_token, auth_scheme, body, extra_headers, holder):
151
+ host, port, path = _split_url(url)
152
+ sock = self._open_socket(host, port, _is_tls(url), holder)
153
+ holder["sock"] = sock
154
+ sock.settimeout(self.config.timeout)
155
+ body_bytes = json.dumps(body).encode("utf-8") if body is not None else None
156
+ headers = self._headers(host, auth_token, auth_scheme, body_bytes, extra_headers)
157
+ self._write_request(sock, method, path, headers, body_bytes)
158
+ resp = http.client.HTTPResponse(sock, method=method.upper())
159
+ resp.begin()
160
+ return resp
161
+
162
+ def _headers(self, host, auth_token, auth_scheme, body_bytes, extra_headers) -> "list[tuple]":
163
+ headers = [
164
+ ("Host", host),
165
+ ("Accept", "*/*"),
166
+ ("Connection", "close"), # server closes at end → clean EOF for read()/SSE
167
+ ]
168
+ if auth_token:
169
+ headers.append(("Authorization", f"{auth_scheme} {auth_token}"))
170
+ for raw in extra_headers or []:
171
+ key, _, value = raw.partition(":")
172
+ headers.append((key.strip(), value.strip()))
173
+ if body_bytes is not None:
174
+ headers.append(("Content-Type", "application/json"))
175
+ headers.append(("Content-Length", str(len(body_bytes))))
176
+ return headers
177
+
178
+ @staticmethod
179
+ def _write_request(sock, method, path, headers, body_bytes) -> None:
180
+ lines = [f"{method.upper()} {path} HTTP/1.1"]
181
+ lines += [f"{k}: {v}" for k, v in headers]
182
+ data = ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1")
183
+ if body_bytes:
184
+ data += body_bytes
185
+ sock.sendall(data)
186
+
187
+ @staticmethod
188
+ def _stream_lines(resp, status: int, cancel_event) -> Iterator[str]:
189
+ """Yield SSE body lines as they arrive, then a trailing ``HTTP_STATUS:<code>`` sentinel."""
190
+ buf = ""
191
+ while True:
192
+ if cancel_event is not None and cancel_event.is_set():
193
+ raise Interrupted("request cancelled by user")
194
+ chunk = resp.read(1024)
195
+ if not chunk:
196
+ break
197
+ buf += chunk.decode("utf-8", "replace")
198
+ while "\n" in buf:
199
+ line, buf = buf.split("\n", 1)
200
+ yield line.rstrip("\r")
201
+ if buf:
202
+ yield buf.rstrip("\r")
203
+ yield f"{_STATUS_MARKER.strip()}{status}"
204
+
205
+ # ── connection + proxy tunnelling ────────────────────────────────────────────
206
+ def _open_socket(self, host: str, port: int, tls: bool, holder: dict):
207
+ proxy = _parse_proxy(self.config.proxy)
208
+ if proxy is None or proxy_bypassed(host):
209
+ raw = socket.create_connection((host, port), timeout=_CONNECT_TIMEOUT)
210
+ else:
211
+ raw = self._open_tunnel(host, port, proxy, holder)
212
+ holder["sock"] = raw
213
+ if not tls:
214
+ return raw
215
+ return ssl.create_default_context().wrap_socket(raw, server_hostname=host)
216
+
217
+ def _open_tunnel(self, host, port, proxy, holder):
218
+ """``CONNECT`` through the proxy (no-auth or Basic) and return the raw socket."""
219
+ phost, pport, basic = proxy
220
+ raw = socket.create_connection((phost, pport), timeout=_CONNECT_TIMEOUT)
221
+ holder["sock"] = raw
222
+ authorization = None
223
+ if basic:
224
+ authorization = "Basic " + base64.b64encode(basic.encode("utf-8")).decode("ascii")
225
+ self._send_connect(raw, host, port, authorization)
226
+ status, _auth_headers = self._read_connect_response(raw)
227
+ if status == 200:
228
+ return raw
229
+ raw.close()
230
+ hint = ("the proxy wants credentials: put them in HTTPS_PROXY as http://user:pass@host:port"
231
+ if status == 407 else "check HTTPS_PROXY")
232
+ raise ProxyError(f"proxy CONNECT failed (HTTP {status})", hint=hint)
233
+
234
+ @staticmethod
235
+ def _send_connect(raw, host, port, authorization: Optional[str]) -> None:
236
+ lines = [f"CONNECT {host}:{port} HTTP/1.1", f"Host: {host}:{port}"]
237
+ if authorization:
238
+ lines.append(f"Proxy-Authorization: {authorization}")
239
+ lines.append("Proxy-Connection: keep-alive")
240
+ raw.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("latin-1"))
241
+
242
+ @staticmethod
243
+ def _read_connect_response(raw) -> "tuple[int, list]":
244
+ """Read a CONNECT response's status + ``Proxy-Authenticate`` headers, consuming any body."""
245
+ data = b""
246
+ while b"\r\n\r\n" not in data:
247
+ chunk = raw.recv(1)
248
+ if not chunk:
249
+ break
250
+ data += chunk
251
+ head, _, _ = data.partition(b"\r\n\r\n")
252
+ text = head.decode("latin-1")
253
+ status_line, _, header_block = text.partition("\r\n")
254
+ pieces = status_line.split(" ", 2)
255
+ status = int(pieces[1]) if len(pieces) > 1 and pieces[1].isdigit() else 0
256
+ auth: List[str] = []
257
+ length = 0
258
+ for line in header_block.split("\r\n"):
259
+ key, _, value = line.partition(":")
260
+ k = key.strip().lower()
261
+ if k == "proxy-authenticate":
262
+ auth.append(value.strip())
263
+ elif k == "content-length":
264
+ try:
265
+ length = int(value.strip())
266
+ except ValueError:
267
+ length = 0
268
+ if length: # drain the response body so the socket is clean
269
+ remaining = length
270
+ while remaining > 0:
271
+ chunk = raw.recv(min(remaining, 4096))
272
+ if not chunk:
273
+ break
274
+ remaining -= len(chunk)
275
+ return status, auth
276
+
277
+ # ── cancellation ─────────────────────────────────────────────────────────────
278
+ @staticmethod
279
+ def _start_watch(holder: dict, cancel_event, stop: threading.Event):
280
+ """Close the socket the moment ESC is pressed, unblocking any in-flight recv."""
281
+ if cancel_event is None:
282
+ return None
283
+
284
+ def _watch() -> None:
285
+ while not stop.is_set():
286
+ if cancel_event.is_set():
287
+ sock = holder.get("sock")
288
+ if sock is not None:
289
+ try:
290
+ sock.close()
291
+ except OSError:
292
+ pass
293
+ return
294
+ time.sleep(0.05)
295
+
296
+ thread = threading.Thread(target=_watch, daemon=True)
297
+ thread.start()
298
+ return thread
299
+
300
+ @staticmethod
301
+ def _close(holder: dict) -> None:
302
+ sock = holder.get("sock")
303
+ if sock is not None:
304
+ try:
305
+ sock.close()
306
+ except OSError:
307
+ pass
308
+
309
+
310
+ def make_transport(config: Config) -> NativeTransport:
311
+ """Factory kept for symmetry with the provider factory; there is one transport."""
312
+ return NativeTransport(config)
scootcli/vision.py ADDED
@@ -0,0 +1,167 @@
1
+ """Image descriptions for the agent: pick a vision-capable model and ask it to describe attachments.
2
+
3
+ No separate vision backend exists any more: vision is a capability of the provider layer. The model is
4
+ ``config.vision_model`` when set, otherwise the best vision-capable model among those the configured
5
+ providers list (``models.VISION_TIER`` order), falling back to the session's own model.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass, field
12
+ from typing import List, Optional
13
+
14
+ from .images import EncodedImage
15
+
16
+ # Default instruction for the describe pass (kept model-agnostic).
17
+ DESCRIBE_INSTRUCTION = (
18
+ "You are assisting a terminal coding agent. Describe the attached image(s) precisely and concisely "
19
+ "for a developer: UI elements, layout, visible text, code, diagrams, colours, and anything a coding "
20
+ "assistant would need to act. If the image shows code or an error message, transcribe the relevant "
21
+ "text verbatim. Output only the description — no preamble or commentary."
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class VisionResult:
27
+ """Neutral result of a describe call (provider-independent)."""
28
+
29
+ text: str
30
+ model: str = ""
31
+ usage: dict = field(default_factory=dict)
32
+
33
+
34
+ def build_content(images: List[EncodedImage], instruction: str) -> list:
35
+ """Build a neutral multimodal ``content`` list (text + image_url parts with data URIs)."""
36
+ content: list = [{"type": "text", "text": instruction}]
37
+ for img in images:
38
+ content.append({"type": "text", "text": f"[{img.name}]"})
39
+ content.append({"type": "image_url", "image_url": {"url": img.data_uri, "detail": "high"}})
40
+ return content
41
+
42
+
43
+ def pick_vision_model(config, provider, fallback: str = "") -> str:
44
+ """``config.vision_model`` if set; else the best vision-capable model the providers list; else ``fallback``."""
45
+ from .models import is_vision_model, resolve_vision
46
+
47
+ wanted = (getattr(config, "vision_model", "auto") or "auto").strip()
48
+ if wanted.lower() != "auto":
49
+ try:
50
+ from .providers.base import qualify
51
+
52
+ return qualify(provider.default_name, wanted) if hasattr(provider, "default_name") else wanted
53
+ except Exception:
54
+ return wanted
55
+ try:
56
+ infos = provider.list_models()
57
+ except Exception:
58
+ infos = []
59
+ ids = []
60
+ for m in infos:
61
+ mid = getattr(m, "id", None) or (m.get("id") if isinstance(m, dict) else None)
62
+ raw = getattr(m, "raw", None) or (m if isinstance(m, dict) else {})
63
+ if mid and is_vision_model({**raw, "id": mid}):
64
+ ids.append(mid)
65
+ if not fallback:
66
+ try:
67
+ fallback = config.resolve_model()
68
+ except Exception:
69
+ fallback = ""
70
+ return resolve_vision(ids, fallback=fallback)
71
+
72
+
73
+ def describe_images(images: List[EncodedImage], provider, model: str, instruction: str = "",
74
+ cancel_event=None) -> VisionResult:
75
+ """One describe call: a non-streaming chat whose user message carries the image parts."""
76
+ if not images:
77
+ return VisionResult(text="")
78
+ messages = [{"role": "user", "content": build_content(images, instruction or DESCRIBE_INSTRUCTION)}]
79
+ result = provider.chat(messages, model=model, max_tokens=1500, cancel_event=cancel_event)
80
+ return VisionResult(text=(result.content or "").strip(), model=result.model, usage=result.usage)
81
+
82
+
83
+ def _augment(clean_text: str, blocks: "List[tuple]") -> str:
84
+ """Fold per-image descriptions back into the user's turn as clearly-separated, labelled text.
85
+
86
+ ``blocks`` is a list of ``(index, EncodedImage, description)``. Each image gets its own labelled
87
+ section (``Image N (name)``) so the model can tell them apart and diff/compare reliably.
88
+ """
89
+ n = len(blocks)
90
+ header = (f"The user attached {n} image(s); a visual description of each follows "
91
+ f"(labelled to match the [Image N] badges shown in the prompt).")
92
+ sections = [f"[Image {idx} — {img.name}]\n{desc}" for idx, img, desc in blocks]
93
+ body = header + "\n\n" + "\n\n".join(sections)
94
+ return f"{clean_text}\n\n{body}" if clean_text else body
95
+
96
+
97
+ def fold_images_into_text(text, config, provider, ui=None, cancel_event=None, model: str = "") -> str:
98
+ """Detect image paths in ``text``, describe them, and return the text augmented with the result.
99
+
100
+ Best-effort and non-destructive: if images are disabled or none are found, ``text`` is returned
101
+ unchanged. Once images *are* detected, the raw paths are always stripped from the returned text —
102
+ on a describe failure a short "couldn't process" note is folded in instead of leaking the paths
103
+ (which the agent can't read and would get confused by). A genuine user interrupt is re-raised so
104
+ the caller can abort the turn cleanly rather than proceeding with a half-processed prompt.
105
+ """
106
+ if not getattr(config, "images", True) or provider is None:
107
+ return text
108
+
109
+ from .errors import Interrupted
110
+ from .images import extract_image_paths, to_data_uri
111
+
112
+ try:
113
+ clean, paths = extract_image_paths(text, root=getattr(config, "root", None))
114
+ except Exception:
115
+ return text
116
+ if not paths:
117
+ return text
118
+
119
+ def _note(reason: str) -> str:
120
+ # Never echo the raw file paths back into the conversation.
121
+ msg = f"[{len(paths)} image(s) were attached but could not be read right now ({reason}).]"
122
+ return f"{clean}\n\n{msg}".strip() if clean else msg
123
+
124
+ try:
125
+ max_bytes = getattr(config, "image_max_bytes", None) or 4 * 1024 * 1024
126
+ encoded: List[EncodedImage] = []
127
+ for p in paths:
128
+ try:
129
+ encoded.append(to_data_uri(p, max_bytes=max_bytes))
130
+ except Exception as exc: # oversized/unreadable — skip it, keep going
131
+ if ui is not None and hasattr(ui, "assistant"):
132
+ ui.assistant(f"skipping image {getattr(p, 'name', p)}: {exc}")
133
+ if not encoded:
134
+ return _note("encoding failed")
135
+ vision_model = pick_vision_model(config, provider, fallback=model)
136
+ if not vision_model:
137
+ return _note("no vision-capable model")
138
+
139
+ ce = cancel_event or threading.Event()
140
+
141
+ def _describe_each():
142
+ # Describe each image in its *own* call so a multi-image prompt gets a complete,
143
+ # correctly-attributed description per image (one shared call tends to merge or drop some).
144
+ out = []
145
+ for idx, img in enumerate(encoded, 1):
146
+ res = describe_images([img], provider, vision_model, cancel_event=ce)
147
+ out.append((idx, img, (res.text or "").strip()))
148
+ return out
149
+
150
+ if ui is not None and hasattr(ui, "activity"):
151
+ with ui.activity(f"looking at {len(encoded)} image(s)…", ce):
152
+ described = _describe_each()
153
+ else:
154
+ described = _describe_each()
155
+
156
+ blocks = [(idx, img, desc) for idx, img, desc in described if desc]
157
+ if not blocks:
158
+ return _note("no description returned")
159
+ if ui is not None and hasattr(ui, "assistant"):
160
+ ui.assistant(f"read {len(encoded)} image(s): "
161
+ + ", ".join(img.name for img in encoded))
162
+ return _augment(clean, blocks)
163
+ except Interrupted:
164
+ raise # a real user interrupt → let the caller abort the turn (don't leak raw paths)
165
+ except Exception:
166
+ return _note("vision unavailable")
167
+
scootcli/workspace.py ADDED
@@ -0,0 +1,105 @@
1
+ """Workspace context: a compact, deterministic map of the repo injected into the agent prompt.
2
+
3
+ Giving the model an at-a-glance view of the project (git branch + a bounded directory tree) means it
4
+ can orient without spending its first step(s) on ``list_dir``. It is intentionally cheap, read-only,
5
+ and tightly bounded so it costs few tokens; it is recomputed once per user turn (see
6
+ :meth:`scootcli.agent.Agent.run_turn`). Toggle with ``SCOOT_WORKSPACE_CONTEXT`` / ``--no-workspace``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+ _SKIP_DIRS = {
16
+ ".git", ".hg", ".svn", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache",
17
+ "node_modules", ".venv", "venv", "dist", "build", ".idea", ".vscode", ".cache",
18
+ ".egg-info",
19
+ }
20
+ _MAX_DEPTH = 2
21
+ _MAX_ENTRIES = 120
22
+ _MAX_FILES_PER_DIR = 12
23
+ _GIT_TIMEOUT = 2
24
+
25
+
26
+ def _git_summary(root: Path) -> str:
27
+ """Best-effort ``branch, N uncommitted change(s)`` for a git repo, or '' if not one/available."""
28
+ def _git(*args) -> "subprocess.CompletedProcess | None":
29
+ try:
30
+ return subprocess.run(
31
+ ["git", "-C", str(root), *args],
32
+ capture_output=True, text=True, timeout=_GIT_TIMEOUT,
33
+ )
34
+ except (OSError, subprocess.SubprocessError):
35
+ return None
36
+
37
+ head = _git("rev-parse", "--abbrev-ref", "HEAD")
38
+ if head is None or head.returncode != 0:
39
+ return ""
40
+ branch = head.stdout.strip() or "(detached)"
41
+ status = _git("status", "--porcelain")
42
+ dirty = 0
43
+ if status is not None and status.returncode == 0:
44
+ dirty = sum(1 for line in status.stdout.splitlines() if line.strip())
45
+ return f"branch {branch}, {dirty} uncommitted change(s)"
46
+
47
+
48
+ def _is_hidden(name: str) -> bool:
49
+ return name.startswith(".") and name not in (".github", ".env.example")
50
+
51
+
52
+ def render_tree(root: Path, max_depth: int = _MAX_DEPTH, max_entries: int = _MAX_ENTRIES) -> str:
53
+ """Return a compact indented tree (dirs first), depth- and entry-bounded."""
54
+ lines: List[str] = []
55
+ budget = max_entries
56
+
57
+ def walk(directory: Path, depth: int) -> None:
58
+ nonlocal budget
59
+ if depth > max_depth or budget <= 0:
60
+ return
61
+ try:
62
+ children = sorted(directory.iterdir(), key=lambda c: (c.is_file(), c.name.lower()))
63
+ except OSError:
64
+ return
65
+ dirs = [c for c in children if c.is_dir()
66
+ and c.name not in _SKIP_DIRS and not c.name.endswith(".egg-info")
67
+ and not _is_hidden(c.name)]
68
+ files = [c for c in children if c.is_file() and not _is_hidden(c.name)]
69
+ indent = " " * depth
70
+ for child in dirs:
71
+ if budget <= 0:
72
+ break
73
+ budget -= 1
74
+ lines.append(f"{indent}{child.name}/")
75
+ walk(child, depth + 1)
76
+ shown = files[:_MAX_FILES_PER_DIR]
77
+ for child in shown:
78
+ if budget <= 0:
79
+ break
80
+ budget -= 1
81
+ lines.append(f"{indent}{child.name}")
82
+ if len(files) > len(shown) and budget > 0:
83
+ lines.append(f"{indent}… (+{len(files) - len(shown)} more files)")
84
+
85
+ walk(Path(root), 0)
86
+ if budget <= 0:
87
+ lines.append("… [truncated]")
88
+ return "\n".join(lines)
89
+
90
+
91
+ def workspace_context(root, max_depth: int = _MAX_DEPTH, max_entries: int = _MAX_ENTRIES) -> str:
92
+ """Compose the workspace map (git summary + tree). Returns '' if nothing useful is available."""
93
+ root = Path(root)
94
+ if not root.is_dir():
95
+ return ""
96
+ parts: List[str] = []
97
+ git = _git_summary(root)
98
+ if git:
99
+ parts.append(f"- git: {git}")
100
+ tree = render_tree(root, max_depth, max_entries)
101
+ if tree:
102
+ indented = "\n".join(" " + line for line in tree.splitlines())
103
+ parts.append("- files:\n" + indented)
104
+ return "\n".join(parts)
105
+