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.
- scootcli/__init__.py +4 -0
- scootcli/__main__.py +9 -0
- scootcli/activity.py +26 -0
- scootcli/agent.py +350 -0
- scootcli/approvals.py +167 -0
- scootcli/auth.py +59 -0
- scootcli/cli.py +276 -0
- scootcli/clipboard.py +89 -0
- scootcli/commands/__init__.py +56 -0
- scootcli/commands/approve.py +38 -0
- scootcli/commands/auth.py +107 -0
- scootcli/commands/base.py +31 -0
- scootcli/commands/compact.py +40 -0
- scootcli/commands/copy.py +23 -0
- scootcli/commands/exit.py +14 -0
- scootcli/commands/forget.py +28 -0
- scootcli/commands/help.py +29 -0
- scootcli/commands/init.py +50 -0
- scootcli/commands/logo.py +51 -0
- scootcli/commands/model.py +61 -0
- scootcli/commands/panel.py +28 -0
- scootcli/commands/reset.py +20 -0
- scootcli/commands/resume.py +31 -0
- scootcli/commands/save.py +29 -0
- scootcli/commands/sessions.py +42 -0
- scootcli/commands/status.py +59 -0
- scootcli/commands/verbosity.py +57 -0
- scootcli/commands/worktree.py +64 -0
- scootcli/commands/yolo.py +20 -0
- scootcli/config.py +241 -0
- scootcli/context.py +82 -0
- scootcli/credentials.py +79 -0
- scootcli/errors.py +87 -0
- scootcli/images.py +169 -0
- scootcli/keys.py +119 -0
- scootcli/lineeditor.py +577 -0
- scootcli/logo.py +116 -0
- scootcli/models.py +120 -0
- scootcli/panel.py +263 -0
- scootcli/preferences.py +87 -0
- scootcli/presets.py +38 -0
- scootcli/project.py +94 -0
- scootcli/prompts.py +100 -0
- scootcli/providers/__init__.py +20 -0
- scootcli/providers/base.py +370 -0
- scootcli/providers/openai_chat.py +142 -0
- scootcli/providers/openai_responses.py +248 -0
- scootcli/providers/registry.py +173 -0
- scootcli/rendering.py +86 -0
- scootcli/repl.py +801 -0
- scootcli/sessions.py +186 -0
- scootcli/status.py +71 -0
- scootcli/tools/__init__.py +68 -0
- scootcli/tools/base.py +152 -0
- scootcli/tools/edit_file.py +72 -0
- scootcli/tools/list_dir.py +47 -0
- scootcli/tools/read_file.py +56 -0
- scootcli/tools/run_shell.py +73 -0
- scootcli/tools/search.py +170 -0
- scootcli/tools/update_plan.py +104 -0
- scootcli/tools/write_file.py +61 -0
- scootcli/transport.py +312 -0
- scootcli/vision.py +167 -0
- scootcli/workspace.py +105 -0
- scootcli/worktree.py +114 -0
- scootcli-0.1.0.dist-info/METADATA +238 -0
- scootcli-0.1.0.dist-info/RECORD +71 -0
- scootcli-0.1.0.dist-info/WHEEL +5 -0
- scootcli-0.1.0.dist-info/entry_points.txt +2 -0
- scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|