meshapi-code 0.3.4__tar.gz → 0.4.1__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.
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/PKG-INFO +1 -1
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/pyproject.toml +1 -1
- meshapi_code-0.4.1/src/meshapi/__init__.py +1 -0
- meshapi_code-0.4.1/src/meshapi/attachments.py +150 -0
- meshapi_code-0.4.1/src/meshapi/cli.py +907 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/src/meshapi/client.py +2 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/src/meshapi/commands.py +45 -10
- meshapi_code-0.4.1/src/meshapi/keywatcher.py +213 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/src/meshapi/permissions.py +3 -3
- meshapi_code-0.4.1/src/meshapi/plan.py +65 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/src/meshapi/render.py +8 -0
- meshapi_code-0.4.1/src/meshapi/statusbar.py +37 -0
- meshapi_code-0.4.1/src/meshapi/tools.py +301 -0
- meshapi_code-0.3.4/src/meshapi/__init__.py +0 -1
- meshapi_code-0.3.4/src/meshapi/cli.py +0 -294
- meshapi_code-0.3.4/src/meshapi/tools.py +0 -129
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/.github/workflows/publish.yml +0 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/.gitignore +0 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/CLAUDE.md +0 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/LICENSE +0 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/NOTICE +0 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/README.md +0 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/src/meshapi/__main__.py +0 -0
- {meshapi_code-0.3.4 → meshapi_code-0.4.1}/src/meshapi/config.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.1"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Image attachment loading for multimodal chat.
|
|
2
|
+
|
|
3
|
+
Builds OpenAI-compatible content parts (`{type: image_url, image_url: {url,
|
|
4
|
+
detail}}`) from local file paths or HTTP(S) URLs. We always base64-encode
|
|
5
|
+
into a `data:image/...;base64,...` URL — the Mesh API docs warn that some
|
|
6
|
+
providers refuse public URLs and require base64, so always-base64 is the
|
|
7
|
+
maximally-compatible default.
|
|
8
|
+
|
|
9
|
+
Also provides a conservative auto-detector for the main loop: scan a user
|
|
10
|
+
prompt for tokens that unambiguously look like image paths or URLs (start
|
|
11
|
+
with `/`, `~`, `./`, `../`, or `http(s)://`, end in a known image extension),
|
|
12
|
+
so a dragged-in file path can be attached without an explicit slash command.
|
|
13
|
+
"""
|
|
14
|
+
import base64
|
|
15
|
+
import mimetypes
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
# Size guardrails. We don't refuse — vision tokens are the user's call — but we
|
|
22
|
+
# do report sizes back so the user sees the cost.
|
|
23
|
+
HARD_LIMIT_BYTES = 20 * 1024 * 1024 # 20 MB
|
|
24
|
+
|
|
25
|
+
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp")
|
|
26
|
+
IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AttachmentError(Exception):
|
|
30
|
+
"""Raised for any user-facing failure to attach an image."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_image(source: str, detail: str = "auto") -> tuple[dict, dict]:
|
|
34
|
+
"""Load an image from a local path or URL into a content part.
|
|
35
|
+
|
|
36
|
+
Returns (content_part, info):
|
|
37
|
+
- content_part is the dict to splice into the message's `content` array:
|
|
38
|
+
`{"type":"image_url","image_url":{"url":"data:...","detail":...}}`
|
|
39
|
+
- info has display metadata: `{"name", "size_bytes", "mime"}`
|
|
40
|
+
|
|
41
|
+
Raises AttachmentError on any failure (not found, too big, wrong MIME,
|
|
42
|
+
network error). Caller is expected to surface the message to the user.
|
|
43
|
+
"""
|
|
44
|
+
if _looks_like_url(source):
|
|
45
|
+
data, mime, name = _fetch_url(source)
|
|
46
|
+
else:
|
|
47
|
+
data, mime, name = _read_local(source)
|
|
48
|
+
|
|
49
|
+
if len(data) > HARD_LIMIT_BYTES:
|
|
50
|
+
raise AttachmentError(
|
|
51
|
+
f"image too large: {len(data) // (1024*1024)} MB "
|
|
52
|
+
f"(limit {HARD_LIMIT_BYTES // (1024*1024)} MB)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
56
|
+
data_url = f"data:{mime};base64,{b64}"
|
|
57
|
+
return (
|
|
58
|
+
{"type": "image_url", "image_url": {"url": data_url, "detail": detail}},
|
|
59
|
+
{"name": name, "size_bytes": len(data), "mime": mime},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_image_tokens(text: str) -> list[str]:
|
|
64
|
+
"""Return tokens in `text` that look like image paths or URLs.
|
|
65
|
+
|
|
66
|
+
Conservative on purpose — only matches:
|
|
67
|
+
- http(s) URLs ending in a known image extension
|
|
68
|
+
- Local paths starting with `/`, `~/`, `./`, or `../` (and ending in an
|
|
69
|
+
image extension, AND pointing at an existing file)
|
|
70
|
+
|
|
71
|
+
Bare filenames (`foo.png`) are NOT matched: too ambiguous with filenames
|
|
72
|
+
mentioned in conversation. Tokens wrapped in backticks or quotes are
|
|
73
|
+
skipped (user's escape hatch).
|
|
74
|
+
|
|
75
|
+
Trailing sentence punctuation (`.,;:!?)`) is trimmed before matching.
|
|
76
|
+
"""
|
|
77
|
+
matches: list[str] = []
|
|
78
|
+
for raw in text.split():
|
|
79
|
+
if not raw or raw[0] in "`\"'":
|
|
80
|
+
continue
|
|
81
|
+
token = raw
|
|
82
|
+
while token and token[-1] in ".,;:!?)":
|
|
83
|
+
token = token[:-1]
|
|
84
|
+
if not token:
|
|
85
|
+
continue
|
|
86
|
+
low = token.lower()
|
|
87
|
+
if low.startswith(("http://", "https://")):
|
|
88
|
+
path_part = token.split("?", 1)[0]
|
|
89
|
+
if path_part.lower().endswith(IMAGE_EXTS):
|
|
90
|
+
matches.append(token)
|
|
91
|
+
continue
|
|
92
|
+
if token.startswith(("/", "~/", "./", "../")):
|
|
93
|
+
if low.endswith(IMAGE_EXTS):
|
|
94
|
+
try:
|
|
95
|
+
p = Path(token).expanduser()
|
|
96
|
+
if p.is_file():
|
|
97
|
+
matches.append(token)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
return matches
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _looks_like_url(s: str) -> bool:
|
|
104
|
+
try:
|
|
105
|
+
u = urlparse(s)
|
|
106
|
+
except (ValueError, AttributeError):
|
|
107
|
+
return False
|
|
108
|
+
return u.scheme in ("http", "https") and bool(u.netloc)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _fetch_url(url: str) -> tuple[bytes, str, str]:
|
|
112
|
+
try:
|
|
113
|
+
with httpx.Client(timeout=30, follow_redirects=True) as client:
|
|
114
|
+
r = client.get(url)
|
|
115
|
+
r.raise_for_status()
|
|
116
|
+
data = r.content
|
|
117
|
+
mime = r.headers.get("content-type", "").split(";")[0].strip()
|
|
118
|
+
except httpx.HTTPError as e:
|
|
119
|
+
raise AttachmentError(f"couldn't fetch {url}: {e}") from None
|
|
120
|
+
|
|
121
|
+
if not mime:
|
|
122
|
+
guessed, _ = mimetypes.guess_type(url)
|
|
123
|
+
mime = guessed or ""
|
|
124
|
+
if mime not in IMAGE_MIMES:
|
|
125
|
+
raise AttachmentError(
|
|
126
|
+
f"URL doesn't look like an image (mime: {mime or 'unknown'})"
|
|
127
|
+
)
|
|
128
|
+
name = Path(urlparse(url).path).name or "image"
|
|
129
|
+
return data, mime, name
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_local(path_str: str) -> tuple[bytes, str, str]:
|
|
133
|
+
path = Path(path_str).expanduser()
|
|
134
|
+
if not path.exists():
|
|
135
|
+
raise AttachmentError(f"file not found: {path}")
|
|
136
|
+
if not path.is_file():
|
|
137
|
+
raise AttachmentError(f"not a regular file: {path}")
|
|
138
|
+
mime, _ = mimetypes.guess_type(str(path))
|
|
139
|
+
if not mime:
|
|
140
|
+
raise AttachmentError(f"couldn't determine MIME type for {path.name}")
|
|
141
|
+
if mime not in IMAGE_MIMES:
|
|
142
|
+
raise AttachmentError(
|
|
143
|
+
f"{path.name} isn't an image (mime: {mime}). "
|
|
144
|
+
"For text documents, use /file."
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
data = path.read_bytes()
|
|
148
|
+
except OSError as e:
|
|
149
|
+
raise AttachmentError(f"can't read {path}: {e}") from None
|
|
150
|
+
return data, mime, path.name
|