meshapi-code 0.4.0__py3-none-any.whl → 0.4.2__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.
meshapi/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.4.0"
1
+ __version__ = "0.4.2"
meshapi/attachments.py ADDED
@@ -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
meshapi/cli.py CHANGED
@@ -23,6 +23,7 @@ from rich.markup import escape as _rich_escape
23
23
  from rich.text import Text
24
24
 
25
25
  from . import __version__, statusbar
26
+ from .attachments import AttachmentError, find_image_tokens, load_image
26
27
  from .client import stream_chat
27
28
  from .commands import handle_command
28
29
  from .config import CONFIG_FILE, HISTORY_FILE, load_config, secure_file
@@ -681,6 +682,7 @@ def main() -> None:
681
682
  "mode": from_str(args.mode),
682
683
  "plan": None, # populated by the model via create_plan
683
684
  "servers": [], # background processes spawned via start_server
685
+ "pending_attachments": [], # image content parts queued via /image
684
686
  }
685
687
 
686
688
  # Mode cycle — used by both the prompt-toolkit keybinding (while at the
@@ -786,7 +788,40 @@ def main() -> None:
786
788
  break
787
789
  continue
788
790
 
789
- state["messages"].append({"role": "user", "content": user_input})
791
+ # Auto-detect image paths/URLs in the prompt and attach them. Tokens
792
+ # that look like unambiguous image paths (start with /, ~, ./, ../,
793
+ # or http(s)://) and end in a known image extension are pulled out
794
+ # and replaced with "[Image #N]" so the text reads naturally.
795
+ auto_text = user_input
796
+ auto_parts: list = []
797
+ queued = state.get("pending_attachments") or []
798
+ n_offset = len(queued)
799
+ for token in find_image_tokens(user_input):
800
+ if token not in auto_text:
801
+ continue # already replaced (duplicate mention)
802
+ try:
803
+ part, info = load_image(token)
804
+ except AttachmentError as e:
805
+ console.print(f"[yellow]Couldn't auto-attach {token}: {e}[/yellow]")
806
+ continue
807
+ n = n_offset + len(auto_parts) + 1
808
+ auto_text = auto_text.replace(token, f"[Image #{n}]")
809
+ auto_parts.append(part)
810
+ size_kb = max(1, info["size_bytes"] // 1024)
811
+ console.print(
812
+ f"[{CODE}]📎 attached {info['name']} ({size_kb} KB, {info['mime']})[/{CODE}]"
813
+ )
814
+
815
+ all_parts = queued + auto_parts
816
+ if all_parts:
817
+ console.print(f"[dim]→ sending {len(all_parts)} image(s) with this prompt[/dim]")
818
+ state["messages"].append({
819
+ "role": "user",
820
+ "content": [{"type": "text", "text": auto_text}] + all_parts,
821
+ })
822
+ state["pending_attachments"] = []
823
+ else:
824
+ state["messages"].append({"role": "user", "content": user_input})
790
825
  console.print()
791
826
 
792
827
  # Tool-calling loop: keep streaming until model returns text without
meshapi/commands.py CHANGED
@@ -3,9 +3,10 @@ from pathlib import Path
3
3
 
4
4
  from rich.panel import Panel
5
5
 
6
+ from .attachments import AttachmentError, load_image
6
7
  from .config import save_config
7
8
  from .permissions import LABELS, Mode, from_str
8
- from .render import console, fmt_usd
9
+ from .render import CODE, console, fmt_usd
9
10
  from .tools import build_system_prompt
10
11
 
11
12
  ROUTES = {"cheapest", "fastest", "balanced"}
@@ -55,6 +56,35 @@ def handle_command(cmd: str, state: dict) -> bool:
55
56
  else:
56
57
  console.print(f"[red]File not found: {path}[/red]")
57
58
 
59
+ elif name == "/image":
60
+ if not arg:
61
+ queued = state.get("pending_attachments") or []
62
+ if not queued:
63
+ console.print(
64
+ "[dim]/image <path-or-url> attach an image to the next prompt[/dim]"
65
+ )
66
+ else:
67
+ console.print(f"[dim]{len(queued)} image(s) queued for next prompt[/dim]")
68
+ else:
69
+ try:
70
+ part, info = load_image(arg.strip())
71
+ except AttachmentError as e:
72
+ console.print(f"[red]Can't attach: {e}[/red]")
73
+ else:
74
+ state.setdefault("pending_attachments", []).append(part)
75
+ size_kb = max(1, info["size_bytes"] // 1024)
76
+ console.print(
77
+ f"[{CODE}]📎 attached {info['name']} ({size_kb} KB, {info['mime']})[/{CODE}]"
78
+ )
79
+
80
+ elif name == "/clear-attach":
81
+ had = len(state.get("pending_attachments") or [])
82
+ state["pending_attachments"] = []
83
+ if had:
84
+ console.print(f"[dim]Dropped {had} queued attachment(s).[/dim]")
85
+ else:
86
+ console.print("[dim]Nothing queued.[/dim]")
87
+
58
88
  elif name == "/system":
59
89
  if arg:
60
90
  state["cfg"]["system"] = arg
@@ -79,15 +109,20 @@ def handle_command(cmd: str, state: dict) -> bool:
79
109
 
80
110
  elif name == "/help":
81
111
  console.print(Panel.fit(
82
- "/exit end session\n"
83
- "/clear reset conversation\n"
84
- "/model <name> switch model (e.g. anthropic/claude-sonnet-4.5)\n"
85
- "/route <mode> cheapest|fastest|balanced|default\n"
86
- "/mode <perm> ask|bypass|none (or shift+tab to cycle)\n"
87
- "/file <path> add file to context\n"
88
- "/system <txt> set system prompt\n"
89
- "/cost show session spend\n"
90
- "/help show this",
112
+ "/exit end session\n"
113
+ "/clear reset conversation\n"
114
+ "/model <name> switch model (e.g. anthropic/claude-sonnet-4.5)\n"
115
+ "/route <mode> cheapest|fastest|balanced|default\n"
116
+ "/mode <perm> ask|bypass|none (or shift+tab to cycle)\n"
117
+ "/file <path> add text file to context\n"
118
+ "/image <path|url> attach an image (base64) to the next prompt\n"
119
+ "/clear-attach drop any queued image attachments\n"
120
+ "/system <txt> set system prompt\n"
121
+ "/cost show session spend\n"
122
+ "/help show this\n\n"
123
+ "[dim]Image paths in a prompt auto-attach: drop /path/img.png in your\n"
124
+ "input and it's sent as a base64 image part. Wrap in backticks to keep\n"
125
+ "it as text. Multiple images per prompt are supported.[/dim]",
91
126
  title="commands",
92
127
  border_style="cyan",
93
128
  ))
meshapi/tools.py CHANGED
@@ -39,6 +39,9 @@ def build_system_prompt(cfg: dict) -> str:
39
39
  "pass flags like --yes, -y, or --no-input; interactive prompts will "
40
40
  "hang and time out. The shell timeout is 120s; if a command would "
41
41
  "take longer, break it into smaller pieces.\n\n"
42
+ "User messages may include images attached as multimodal content "
43
+ "parts (image_url with a data: or https: URL). Look at them carefully "
44
+ "and reference what you see when relevant.\n\n"
42
45
  "For long-running servers (dev servers like `npm run dev` / `vite` / "
43
46
  "`next dev`, `flask run`, `python -m http.server`, file watchers, etc.) "
44
47
  "use the start_server tool — NOT run_bash. run_bash will kill the "
@@ -209,6 +212,18 @@ def execute(name: str, arguments: dict) -> str:
209
212
  path = arguments.get("path")
210
213
  if not path:
211
214
  return "Error: read_file requires a `path` argument."
215
+ # Guard against reading an image as text — return a helpful message
216
+ # so the model directs the user to /image instead of looping on a
217
+ # utf-8 decode error.
218
+ suffix = Path(path).suffix.lower()
219
+ if suffix in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
220
+ return (
221
+ f"Error: {Path(path).name} is an image file and read_file only "
222
+ "reads text. Tell the user to attach the image instead — either "
223
+ f"by typing `/image {path}` or by including the path in their "
224
+ "next prompt (auto-attach picks up paths starting with /, ~, "
225
+ "./, ../, or http(s)://)."
226
+ )
212
227
  try:
213
228
  return Path(path).expanduser().read_text()
214
229
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshapi-code
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Terminal chat for Mesh API — OpenAI-compatible LLM gateway
5
5
  Project-URL: Homepage, https://meshapi.ai
6
6
  Project-URL: Documentation, https://docs.meshapi.ai
@@ -0,0 +1,19 @@
1
+ meshapi/__init__.py,sha256=6hfVa12Q-nXyUEXr6SyKpqPEDJW6vlRHyPxlA27PfTs,22
2
+ meshapi/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ meshapi/attachments.py,sha256=Mpsxm66QT_cJV4TXlnYU23ZhFm4vdzFEyXDenXjxpEU,5475
4
+ meshapi/cli.py,sha256=N2uqztGWVCP3z6_kkPKUieWIiTrg187LN5Zy5640e_k,37447
5
+ meshapi/client.py,sha256=Rtc-8W9XncxPlV6qQ9I_c25BizyBHYNiIy8Eb3kSaEw,2920
6
+ meshapi/commands.py,sha256=MdaXgpfWdkg0bDE-Q-ufin0Wivcu66LOlJ2nzdvNqco,5302
7
+ meshapi/config.py,sha256=DKFljJh1DfSispptYA7mtJFBVMzE8MMyb5UvcelxwTY,2349
8
+ meshapi/keywatcher.py,sha256=tWVSLWZY-p08CcOd10Xvf5TrMGfjDaKDzYJRSfe4kPo,8057
9
+ meshapi/permissions.py,sha256=BPLYiPrlLR1js9k64szm9b11fXYx0ZZcQ2a08GLNRg8,1033
10
+ meshapi/plan.py,sha256=JWgzm2Qtbdso7nnoR7K896d7n7ufwlhT-2F09PGXXKs,2561
11
+ meshapi/render.py,sha256=VwgDbYSElwEJ0WhSMpRZ8Tw_EA0A09s8D4yVh_nUL3o,4737
12
+ meshapi/statusbar.py,sha256=yqF6fzCaZMXMzUmX1vzmKWAMbCe_YRsbnA27meA3vaw,1361
13
+ meshapi/tools.py,sha256=lL8oxj7uxCyojmRvgjlzZSxs-DoIc8fhxnhiuNfm3RA,13728
14
+ meshapi_code-0.4.2.dist-info/METADATA,sha256=gCzLZHiASN2ljGD_CXxQpphNq5YKtWh4La50cxl69Tw,7595
15
+ meshapi_code-0.4.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ meshapi_code-0.4.2.dist-info/entry_points.txt,sha256=ZCXZ_SgrhWIQEHSjAXz0pUlyGbIQKZ68vp_Cg1Y0rME,45
17
+ meshapi_code-0.4.2.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
18
+ meshapi_code-0.4.2.dist-info/licenses/NOTICE,sha256=wF-6Apse4eVIOpbNP3WLtTaOJClNFK7Jok2BnUvSo9U,191
19
+ meshapi_code-0.4.2.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- meshapi/__init__.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
2
- meshapi/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
- meshapi/cli.py,sha256=wLtLmRFgN9AQ7Z050I0hCfIQKcIgM3-kjy0CNcoq5Nw,35752
4
- meshapi/client.py,sha256=Rtc-8W9XncxPlV6qQ9I_c25BizyBHYNiIy8Eb3kSaEw,2920
5
- meshapi/commands.py,sha256=fc_qnTa_oZgn5CRQ7GrpUm1Zclmat4McLdEV5YX5Wvg,3662
6
- meshapi/config.py,sha256=DKFljJh1DfSispptYA7mtJFBVMzE8MMyb5UvcelxwTY,2349
7
- meshapi/keywatcher.py,sha256=tWVSLWZY-p08CcOd10Xvf5TrMGfjDaKDzYJRSfe4kPo,8057
8
- meshapi/permissions.py,sha256=BPLYiPrlLR1js9k64szm9b11fXYx0ZZcQ2a08GLNRg8,1033
9
- meshapi/plan.py,sha256=JWgzm2Qtbdso7nnoR7K896d7n7ufwlhT-2F09PGXXKs,2561
10
- meshapi/render.py,sha256=VwgDbYSElwEJ0WhSMpRZ8Tw_EA0A09s8D4yVh_nUL3o,4737
11
- meshapi/statusbar.py,sha256=yqF6fzCaZMXMzUmX1vzmKWAMbCe_YRsbnA27meA3vaw,1361
12
- meshapi/tools.py,sha256=6KXuoRW5-XwIkGfLd-v2Ylr7UqVePEOwZFyCP6CT20M,12825
13
- meshapi_code-0.4.0.dist-info/METADATA,sha256=qEXN-tvjfzPDGPck2aX_arFb74_eoCF3fHBUtp6MrU0,7595
14
- meshapi_code-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
- meshapi_code-0.4.0.dist-info/entry_points.txt,sha256=ZCXZ_SgrhWIQEHSjAXz0pUlyGbIQKZ68vp_Cg1Y0rME,45
16
- meshapi_code-0.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
17
- meshapi_code-0.4.0.dist-info/licenses/NOTICE,sha256=wF-6Apse4eVIOpbNP3WLtTaOJClNFK7Jok2BnUvSo9U,191
18
- meshapi_code-0.4.0.dist-info/RECORD,,