meshapi-code 0.4.2__py3-none-any.whl → 0.4.4__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 +1 -1
- meshapi/attachments.py +53 -22
- meshapi/cli.py +268 -57
- meshapi/commands.py +32 -7
- meshapi/config.py +50 -0
- meshapi/permissions.py +48 -13
- meshapi/plan.py +21 -0
- meshapi/safety.py +261 -0
- meshapi/statusbar.py +82 -5
- meshapi/tools.py +27 -10
- {meshapi_code-0.4.2.dist-info → meshapi_code-0.4.4.dist-info}/METADATA +1 -1
- meshapi_code-0.4.4.dist-info/RECORD +20 -0
- meshapi_code-0.4.2.dist-info/RECORD +0 -19
- {meshapi_code-0.4.2.dist-info → meshapi_code-0.4.4.dist-info}/WHEEL +0 -0
- {meshapi_code-0.4.2.dist-info → meshapi_code-0.4.4.dist-info}/entry_points.txt +0 -0
- {meshapi_code-0.4.2.dist-info → meshapi_code-0.4.4.dist-info}/licenses/LICENSE +0 -0
- {meshapi_code-0.4.2.dist-info → meshapi_code-0.4.4.dist-info}/licenses/NOTICE +0 -0
meshapi/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.4.
|
|
1
|
+
__version__ = "0.4.4"
|
meshapi/attachments.py
CHANGED
|
@@ -13,11 +13,19 @@ so a dragged-in file path can be attached without an explicit slash command.
|
|
|
13
13
|
"""
|
|
14
14
|
import base64
|
|
15
15
|
import mimetypes
|
|
16
|
+
import re
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from urllib.parse import urlparse
|
|
18
19
|
|
|
19
20
|
import httpx
|
|
20
21
|
|
|
22
|
+
# Tokenizer for find_image_tokens: a single- or double-quoted span (kept
|
|
23
|
+
# whole, INCLUDING internal spaces) OR a run of non-whitespace. Quoted
|
|
24
|
+
# alternatives come first so a drag-dropped path like
|
|
25
|
+
# `'/Users/me/snake game/img.png'` stays one token instead of being shredded
|
|
26
|
+
# on the spaces by str.split().
|
|
27
|
+
_TOKEN_RE = re.compile(r"'[^']*'|\"[^\"]*\"|\S+")
|
|
28
|
+
|
|
21
29
|
# Size guardrails. We don't refuse — vision tokens are the user's call — but we
|
|
22
30
|
# do report sizes back so the user sees the cost.
|
|
23
31
|
HARD_LIMIT_BYTES = 20 * 1024 * 1024 # 20 MB
|
|
@@ -60,43 +68,58 @@ def load_image(source: str, detail: str = "auto") -> tuple[dict, dict]:
|
|
|
60
68
|
)
|
|
61
69
|
|
|
62
70
|
|
|
63
|
-
def find_image_tokens(text: str) -> list[str]:
|
|
64
|
-
"""Return
|
|
71
|
+
def find_image_tokens(text: str) -> list[tuple[str, str]]:
|
|
72
|
+
"""Return `(raw_token, normalized)` pairs for image references in `text`.
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
Strategy: be liberal about what looks like a file/URL, then verify by
|
|
75
|
+
actually checking existence (local) or extension (URL). The user's
|
|
76
|
+
natural workflow is "drag the file in" — terminals wrap drag-dropped
|
|
77
|
+
paths in single quotes when convenient, so we strip wrapping quotes.
|
|
78
|
+
A bare filename like `screenshot.png` also matches if it exists in the
|
|
79
|
+
cwd. The only escape is a backtick prefix: `` `foo.png` `` is treated
|
|
80
|
+
as text.
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
- http(s) URLs ending in a known image extension
|
|
83
|
+
- Any token that, after stripping wrapping quotes and trailing
|
|
84
|
+
punctuation, resolves to an existing file with an image extension
|
|
74
85
|
|
|
75
|
-
|
|
86
|
+
`raw_token` is the exact substring to find/replace in the original
|
|
87
|
+
text (so quotes are preserved when stripping); `normalized` is the
|
|
88
|
+
cleaned path or URL to pass into load_image().
|
|
76
89
|
"""
|
|
77
|
-
matches: list[str] = []
|
|
78
|
-
for raw in
|
|
79
|
-
if not raw
|
|
90
|
+
matches: list[tuple[str, str]] = []
|
|
91
|
+
for raw in _TOKEN_RE.findall(text):
|
|
92
|
+
if not raw:
|
|
93
|
+
continue
|
|
94
|
+
# Backtick prefix = explicit "treat as text" escape.
|
|
95
|
+
if raw.startswith("`"):
|
|
80
96
|
continue
|
|
97
|
+
|
|
81
98
|
token = raw
|
|
99
|
+
# Strip a matching wrapping pair of single or double quotes
|
|
100
|
+
# (drag-drop on macOS Terminal/iTerm2 quotes paths automatically).
|
|
101
|
+
if len(token) >= 2 and token[0] == token[-1] and token[0] in "'\"":
|
|
102
|
+
token = token[1:-1]
|
|
103
|
+
# Strip trailing sentence punctuation but leave URL query strings.
|
|
82
104
|
while token and token[-1] in ".,;:!?)":
|
|
83
105
|
token = token[:-1]
|
|
84
106
|
if not token:
|
|
85
107
|
continue
|
|
108
|
+
|
|
86
109
|
low = token.lower()
|
|
87
110
|
if low.startswith(("http://", "https://")):
|
|
88
111
|
path_part = token.split("?", 1)[0]
|
|
89
112
|
if path_part.lower().endswith(IMAGE_EXTS):
|
|
90
|
-
matches.append(token)
|
|
113
|
+
matches.append((raw, token))
|
|
91
114
|
continue
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
|
|
116
|
+
if low.endswith(IMAGE_EXTS):
|
|
117
|
+
try:
|
|
118
|
+
p = Path(token).expanduser()
|
|
119
|
+
if p.is_file():
|
|
120
|
+
matches.append((raw, token))
|
|
121
|
+
except (OSError, ValueError):
|
|
122
|
+
pass
|
|
100
123
|
return matches
|
|
101
124
|
|
|
102
125
|
|
|
@@ -109,6 +132,14 @@ def _looks_like_url(s: str) -> bool:
|
|
|
109
132
|
|
|
110
133
|
|
|
111
134
|
def _fetch_url(url: str) -> tuple[bytes, str, str]:
|
|
135
|
+
# SSRF guard: refuse loopback/private/link-local before issuing the
|
|
136
|
+
# request. Imported lazily so attachments.py doesn't pull in safety on
|
|
137
|
+
# every code path.
|
|
138
|
+
from .safety import is_url_safe_for_fetch
|
|
139
|
+
|
|
140
|
+
ok, reason = is_url_safe_for_fetch(url)
|
|
141
|
+
if not ok:
|
|
142
|
+
raise AttachmentError(f"refusing to fetch {url}: {reason}")
|
|
112
143
|
try:
|
|
113
144
|
with httpx.Client(timeout=30, follow_redirects=True) as client:
|
|
114
145
|
r = client.get(url)
|
meshapi/cli.py
CHANGED
|
@@ -26,10 +26,14 @@ from . import __version__, statusbar
|
|
|
26
26
|
from .attachments import AttachmentError, find_image_tokens, load_image
|
|
27
27
|
from .client import stream_chat
|
|
28
28
|
from .commands import handle_command
|
|
29
|
-
from .config import
|
|
29
|
+
from .config import (
|
|
30
|
+
CONFIG_FILE, HISTORY_FILE, clear_servers_file, load_config, load_servers,
|
|
31
|
+
save_servers, secure_file,
|
|
32
|
+
)
|
|
30
33
|
from .keywatcher import KeyWatcher
|
|
31
|
-
from .permissions import
|
|
34
|
+
from .permissions import AUTO_APPROVE, Mode, from_str, next_mode
|
|
32
35
|
from .plan import Plan
|
|
36
|
+
from . import safety
|
|
33
37
|
from .render import (
|
|
34
38
|
BRAND, BRAND_BG, BRAND_BG_FG, BRAND_DIM, CODE, console, fmt_usd, pretty_cwd, render_stream,
|
|
35
39
|
)
|
|
@@ -78,8 +82,8 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|
|
78
82
|
p.add_argument(
|
|
79
83
|
"--mode",
|
|
80
84
|
choices=[m.value for m in Mode],
|
|
81
|
-
default="
|
|
82
|
-
help="Tool permission mode (default: ask). Cycle in-session with shift+tab.",
|
|
85
|
+
default="default",
|
|
86
|
+
help="Tool permission mode (default: ask each tool). Cycle in-session with shift+tab.",
|
|
83
87
|
)
|
|
84
88
|
return p.parse_args(argv)
|
|
85
89
|
|
|
@@ -410,11 +414,88 @@ def _kill_server(pid: int) -> None:
|
|
|
410
414
|
pass
|
|
411
415
|
|
|
412
416
|
|
|
417
|
+
def _persist_servers(state: dict) -> None:
|
|
418
|
+
"""Write current live servers to ~/.meshapi/servers.json. Best-effort —
|
|
419
|
+
a corrupt or missing file should never block the REPL."""
|
|
420
|
+
try:
|
|
421
|
+
save_servers(state.get("servers", []))
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
|
|
413
426
|
def _shutdown_servers(state: dict) -> None:
|
|
414
|
-
"""Kill every server we launched. Called on meshapi exit
|
|
427
|
+
"""Kill every server we launched. Called on meshapi exit (clean or
|
|
428
|
+
via SIGTERM/SIGHUP). Also wipes the persisted servers file so the
|
|
429
|
+
next launch doesn't offer to kill ghosts."""
|
|
415
430
|
for srv in state.get("servers", []):
|
|
416
431
|
_kill_server(srv["pid"])
|
|
417
432
|
state["servers"] = []
|
|
433
|
+
clear_servers_file()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _adopt_orphaned_servers(state: dict) -> None:
|
|
437
|
+
"""At startup, look for processes recorded by a previous (crashed)
|
|
438
|
+
meshapi and offer to terminate them. A hard kill of meshapi (SIGKILL,
|
|
439
|
+
laptop sleep + battery, segfault) skips atexit/SIGTERM, so this is
|
|
440
|
+
the safety net that catches leaked servers."""
|
|
441
|
+
rec = load_servers()
|
|
442
|
+
if not rec:
|
|
443
|
+
return
|
|
444
|
+
live = []
|
|
445
|
+
for s in rec:
|
|
446
|
+
pid = s.get("pid") if isinstance(s, dict) else None
|
|
447
|
+
if not isinstance(pid, int):
|
|
448
|
+
continue
|
|
449
|
+
try:
|
|
450
|
+
os.kill(pid, 0) # signal 0 = existence check, no actual signal
|
|
451
|
+
except (ProcessLookupError, PermissionError):
|
|
452
|
+
continue
|
|
453
|
+
except OSError:
|
|
454
|
+
continue
|
|
455
|
+
live.append(s)
|
|
456
|
+
if not live:
|
|
457
|
+
clear_servers_file()
|
|
458
|
+
return
|
|
459
|
+
console.print(
|
|
460
|
+
f"[yellow]Found {len(live)} background server(s) left running from a "
|
|
461
|
+
"previous session:[/yellow]"
|
|
462
|
+
)
|
|
463
|
+
for s in live:
|
|
464
|
+
console.print(
|
|
465
|
+
f" [dim]pid {s.get('pid')}, port {s.get('port')}, "
|
|
466
|
+
f"{s.get('cmd', '')}[/dim]"
|
|
467
|
+
)
|
|
468
|
+
try:
|
|
469
|
+
ans = console.input(
|
|
470
|
+
"Kill them now? [bold]y[/bold] (yes) / [bold]n[/bold] (no) › "
|
|
471
|
+
).strip().lower()
|
|
472
|
+
except (KeyboardInterrupt, EOFError):
|
|
473
|
+
return
|
|
474
|
+
if ans in ("y", "yes"):
|
|
475
|
+
for s in live:
|
|
476
|
+
_kill_server(s.get("pid", 0))
|
|
477
|
+
clear_servers_file()
|
|
478
|
+
console.print(f"[dim]Killed {len(live)} server(s).[/dim]")
|
|
479
|
+
else:
|
|
480
|
+
clear_servers_file() # don't keep asking on every launch
|
|
481
|
+
console.print("[dim]Leaving them running.[/dim]")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _check_image_cap(state: dict, additional_bytes: int) -> tuple[bool, str]:
|
|
485
|
+
"""Per-session image-bytes budget. Counts both already-sent and queued
|
|
486
|
+
attachments — clearing the queue (/clear-attach) releases them again."""
|
|
487
|
+
sent = state.get("session_image_bytes", 0)
|
|
488
|
+
queued = sum(int(a.get("size_bytes", 0))
|
|
489
|
+
for a in (state.get("pending_attachments") or []))
|
|
490
|
+
total = sent + queued + additional_bytes
|
|
491
|
+
if total > safety.SESSION_IMAGE_BYTE_CAP:
|
|
492
|
+
cap_mb = safety.SESSION_IMAGE_BYTE_CAP // (1024 * 1024)
|
|
493
|
+
used_mb = max(1, (sent + queued) // (1024 * 1024))
|
|
494
|
+
return False, (
|
|
495
|
+
f"would exceed session image budget ({cap_mb} MB total, "
|
|
496
|
+
f"{used_mb} MB used)"
|
|
497
|
+
)
|
|
498
|
+
return True, ""
|
|
418
499
|
|
|
419
500
|
|
|
420
501
|
def _handle_start_server(args: dict, state: dict) -> str:
|
|
@@ -520,10 +601,23 @@ def _handle_start_server(args: dict, state: dict) -> str:
|
|
|
520
601
|
state.setdefault("servers", []).append({
|
|
521
602
|
"pid": proc.pid, "port": port, "cmd": cmd, "url": url,
|
|
522
603
|
})
|
|
604
|
+
_persist_servers(state) # survive a hard kill / crash
|
|
523
605
|
|
|
606
|
+
# Make the URL big, plain, on its own line — most terminals
|
|
607
|
+
# auto-detect bare URLs as cmd-clickable, which is more reliable
|
|
608
|
+
# than rich's OSC-8 `[link=...]` markup that some terminals
|
|
609
|
+
# (xterm.js, older Terminal.app) strip silently.
|
|
610
|
+
from rich.panel import Panel
|
|
524
611
|
console.print(f" [green]✓ ready in {elapsed:.1f}s[/green]")
|
|
525
612
|
console.print()
|
|
526
|
-
console.print(
|
|
613
|
+
console.print(Panel.fit(
|
|
614
|
+
f"[bold green]{url}[/bold green]\n"
|
|
615
|
+
f"[dim]server running in the background · pid {proc.pid} · "
|
|
616
|
+
"⌘-click or paste the URL in your browser[/dim]",
|
|
617
|
+
title="🌐 ready",
|
|
618
|
+
border_style="green",
|
|
619
|
+
padding=(0, 2),
|
|
620
|
+
))
|
|
527
621
|
console.print()
|
|
528
622
|
if preview.strip():
|
|
529
623
|
console.print(" [dim]── server output ──[/dim]")
|
|
@@ -533,10 +627,14 @@ def _handle_start_server(args: dict, state: dict) -> str:
|
|
|
533
627
|
console.print(f" [dim]{_rich_escape(line)}[/dim]")
|
|
534
628
|
|
|
535
629
|
return (
|
|
536
|
-
f"Server
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
"
|
|
630
|
+
f"Server up at {url} (pid {proc.pid}, ready in {elapsed:.1f}s).\n"
|
|
631
|
+
"The user can already see the URL in their terminal — it was "
|
|
632
|
+
"printed by the CLI. Respond with a SINGLE short text line "
|
|
633
|
+
"(e.g. 'Server's up at " + url + " — open it in your browser') "
|
|
634
|
+
"and END THE TURN. Do NOT call any more tools this turn — "
|
|
635
|
+
"no curl, no read_file, no anything. The server keeps running "
|
|
636
|
+
"in the background until meshapi exits; the user will interact "
|
|
637
|
+
"with it through the browser, not through you."
|
|
540
638
|
)
|
|
541
639
|
time.sleep(0.2)
|
|
542
640
|
|
|
@@ -614,7 +712,42 @@ def handle_tool_calls(tool_calls: list, mode: Mode, state: dict) -> None:
|
|
|
614
712
|
# effects, so we don't gate them on the approval prompt.
|
|
615
713
|
result = _handle_plan_tool(tc["name"], args, state)
|
|
616
714
|
else:
|
|
617
|
-
|
|
715
|
+
# Per-mode auto-approval: each Mode declares which tool names
|
|
716
|
+
# bypass the y/n prompt. Anything not in the set, OR anything
|
|
717
|
+
# that fails a safety check, falls back to confirmation —
|
|
718
|
+
# even in BYPASS we ask before truly dangerous shapes
|
|
719
|
+
# (sensitive paths, `rm -rf /`, sudo, curl | sh, ...).
|
|
720
|
+
auto_approved = tc["name"] in AUTO_APPROVE.get(mode, set())
|
|
721
|
+
safety_reason: str = ""
|
|
722
|
+
if auto_approved and tc["name"] == "write_file":
|
|
723
|
+
ok, reason = safety.is_path_safe_for_auto_write(
|
|
724
|
+
args.get("path"), mode
|
|
725
|
+
)
|
|
726
|
+
if not ok:
|
|
727
|
+
auto_approved = False
|
|
728
|
+
safety_reason = reason or "path safety check failed"
|
|
729
|
+
elif auto_approved and tc["name"] == "read_file":
|
|
730
|
+
# BYPASS auto-approves reads; we still block sensitive
|
|
731
|
+
# paths so the model can't silently leak ~/.ssh/...
|
|
732
|
+
# to the upstream provider.
|
|
733
|
+
ok, reason = safety.is_path_safe_for_auto_read(
|
|
734
|
+
args.get("path"), mode
|
|
735
|
+
)
|
|
736
|
+
if not ok:
|
|
737
|
+
auto_approved = False
|
|
738
|
+
safety_reason = reason or "path safety check failed"
|
|
739
|
+
elif auto_approved and tc["name"] in ("run_bash", "start_server"):
|
|
740
|
+
ok, reason = safety.is_command_safe_for_auto(
|
|
741
|
+
args.get("command"), mode
|
|
742
|
+
)
|
|
743
|
+
if not ok:
|
|
744
|
+
auto_approved = False
|
|
745
|
+
safety_reason = reason or "command safety check failed"
|
|
746
|
+
if not auto_approved and safety_reason:
|
|
747
|
+
console.print(
|
|
748
|
+
f"[yellow]⚠ auto-approval blocked: {safety_reason}[/yellow]"
|
|
749
|
+
)
|
|
750
|
+
approved = auto_approved or confirm_tool_call(
|
|
618
751
|
tc["name"], args, watcher=state.get("watcher")
|
|
619
752
|
)
|
|
620
753
|
if approved:
|
|
@@ -682,7 +815,10 @@ def main() -> None:
|
|
|
682
815
|
"mode": from_str(args.mode),
|
|
683
816
|
"plan": None, # populated by the model via create_plan
|
|
684
817
|
"servers": [], # background processes spawned via start_server
|
|
685
|
-
"pending_attachments": [], #
|
|
818
|
+
"pending_attachments": [], # list of {"part","size_bytes","name"}
|
|
819
|
+
# Cumulative bytes of attachments already sent to the model.
|
|
820
|
+
# Enforces safety.SESSION_IMAGE_BYTE_CAP across the whole session.
|
|
821
|
+
"session_image_bytes": 0,
|
|
686
822
|
}
|
|
687
823
|
|
|
688
824
|
# Mode cycle — used by both the prompt-toolkit keybinding (while at the
|
|
@@ -699,22 +835,14 @@ def main() -> None:
|
|
|
699
835
|
_cycle_mode()
|
|
700
836
|
event.app.invalidate()
|
|
701
837
|
|
|
702
|
-
#
|
|
703
|
-
#
|
|
704
|
-
#
|
|
705
|
-
#
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
elif m == Mode.NONE:
|
|
711
|
-
color = "ansiyellow"
|
|
712
|
-
else:
|
|
713
|
-
color = "ansigreen"
|
|
714
|
-
return FormattedText([
|
|
715
|
-
(f"bold {color}", f"▶▶ {LABELS[m]}"),
|
|
716
|
-
("ansibrightblack", " ⇧⇥"),
|
|
717
|
-
])
|
|
838
|
+
# Prompt is just the "› " marker. The mode indicator is rendered by
|
|
839
|
+
# statusbar.print_line ABOVE the cwd separator each turn (matches the
|
|
840
|
+
# user's mockup — no extra indicator on the input line). Trade-off:
|
|
841
|
+
# shift+tab during typing still cycles the mode internally, but the
|
|
842
|
+
# repainted line is only visible at the next prompt or after the next
|
|
843
|
+
# tool batch (handle_tool_calls also fires statusbar.print_line).
|
|
844
|
+
def prompt_message():
|
|
845
|
+
return FormattedText([("class:prompt", "› ")])
|
|
718
846
|
|
|
719
847
|
# Out-of-prompt key watcher: lets shift+tab cycle mode during streaming
|
|
720
848
|
# and tool execution. Paused while prompt_toolkit owns stdin.
|
|
@@ -731,6 +859,7 @@ def main() -> None:
|
|
|
731
859
|
)
|
|
732
860
|
|
|
733
861
|
render_banner(cfg)
|
|
862
|
+
_adopt_orphaned_servers(state)
|
|
734
863
|
watcher.start() # captures shift+tab whenever prompt_toolkit isn't reading
|
|
735
864
|
|
|
736
865
|
# Make sure backgrounded servers die with us — even if Python exits via
|
|
@@ -753,10 +882,12 @@ def main() -> None:
|
|
|
753
882
|
|
|
754
883
|
while True:
|
|
755
884
|
try:
|
|
756
|
-
#
|
|
757
|
-
#
|
|
758
|
-
#
|
|
759
|
-
#
|
|
885
|
+
# cwd separator above the input box. The mode indicator is no
|
|
886
|
+
# longer printed here — it lives in the bottom_toolbar below the
|
|
887
|
+
# prompt, which prompt_toolkit repaints live on shift+tab:
|
|
888
|
+
# ──────────────────────────────────────────── cli_for_meshapi_v1
|
|
889
|
+
# › ...
|
|
890
|
+
# ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt
|
|
760
891
|
console.rule(
|
|
761
892
|
title=f"[{BRAND_DIM}]{Path.cwd().name}[/{BRAND_DIM}]",
|
|
762
893
|
align="right",
|
|
@@ -765,16 +896,21 @@ def main() -> None:
|
|
|
765
896
|
)
|
|
766
897
|
with watcher.paused():
|
|
767
898
|
# Hand stdin off to prompt_toolkit (canonical-mode termios).
|
|
899
|
+
# The prompt itself is just "› "; the mode indicator is the
|
|
900
|
+
# bottom_toolbar, repainted live by the s-tab binding's
|
|
901
|
+
# event.app.invalidate(). "noreverse" kills prompt_toolkit's
|
|
902
|
+
# default inverted bar so the toggle reads as plain text.
|
|
768
903
|
user_input = session.prompt(
|
|
769
|
-
|
|
770
|
-
|
|
904
|
+
prompt_message,
|
|
905
|
+
bottom_toolbar=lambda: statusbar.bottom_toolbar(state),
|
|
771
906
|
style=Style.from_dict({
|
|
772
907
|
"prompt": f"bold fg:{BRAND} bg:{BRAND_BG}",
|
|
773
908
|
"": f"fg:{BRAND_BG_FG} bg:{BRAND_BG}",
|
|
774
|
-
"
|
|
909
|
+
"bottom-toolbar": "noreverse bg:default",
|
|
775
910
|
}),
|
|
776
911
|
)
|
|
777
912
|
console.rule(style=BRAND_DIM, characters="─")
|
|
913
|
+
console.print() # bottom padding under the input box per the mockup
|
|
778
914
|
except (KeyboardInterrupt, EOFError):
|
|
779
915
|
_shutdown_servers(state)
|
|
780
916
|
watcher.stop()
|
|
@@ -788,37 +924,61 @@ def main() -> None:
|
|
|
788
924
|
break
|
|
789
925
|
continue
|
|
790
926
|
|
|
791
|
-
# Auto-detect image paths/URLs in the prompt and attach them.
|
|
792
|
-
#
|
|
793
|
-
#
|
|
794
|
-
#
|
|
927
|
+
# Auto-detect image paths/URLs in the prompt and attach them. The
|
|
928
|
+
# detector is liberal — drag-dropped paths (often quoted), bare
|
|
929
|
+
# filenames that exist in cwd, and URLs all work. Each match comes
|
|
930
|
+
# back as (raw_token, normalized): we replace `raw_token` in the
|
|
931
|
+
# original text (so wrapping quotes go too) with `[Image #N]`.
|
|
795
932
|
auto_text = user_input
|
|
796
|
-
|
|
933
|
+
auto_attachments: list = [] # list of {"part","size_bytes","name"}
|
|
797
934
|
queued = state.get("pending_attachments") or []
|
|
798
935
|
n_offset = len(queued)
|
|
799
|
-
for
|
|
800
|
-
if
|
|
801
|
-
continue # already replaced (duplicate mention)
|
|
936
|
+
for raw_token, source in find_image_tokens(user_input):
|
|
937
|
+
if raw_token not in auto_text:
|
|
938
|
+
continue # already replaced (duplicate mention in same prompt)
|
|
802
939
|
try:
|
|
803
|
-
part, info = load_image(
|
|
940
|
+
part, info = load_image(source)
|
|
804
941
|
except AttachmentError as e:
|
|
805
|
-
console.print(f"[yellow]Couldn't auto-attach {
|
|
942
|
+
console.print(f"[yellow]Couldn't auto-attach {source}: {e}[/yellow]")
|
|
806
943
|
continue
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
944
|
+
# Session-cap check: refuse attachments that would push us past
|
|
945
|
+
# the cumulative budget. Already-sent + queued + this one.
|
|
946
|
+
ok, reason = _check_image_cap(
|
|
947
|
+
state,
|
|
948
|
+
info["size_bytes"]
|
|
949
|
+
+ sum(int(a.get("size_bytes", 0)) for a in auto_attachments),
|
|
950
|
+
)
|
|
951
|
+
if not ok:
|
|
952
|
+
console.print(
|
|
953
|
+
f"[red]Skipping {info['name']}: {reason}[/red]"
|
|
954
|
+
)
|
|
955
|
+
continue
|
|
956
|
+
n = n_offset + len(auto_attachments) + 1
|
|
957
|
+
auto_text = auto_text.replace(raw_token, f"[Image #{n}]")
|
|
958
|
+
auto_attachments.append({
|
|
959
|
+
"part": part,
|
|
960
|
+
"size_bytes": info["size_bytes"],
|
|
961
|
+
"name": info["name"],
|
|
962
|
+
})
|
|
810
963
|
size_kb = max(1, info["size_bytes"] // 1024)
|
|
811
964
|
console.print(
|
|
812
965
|
f"[{CODE}]📎 attached {info['name']} ({size_kb} KB, {info['mime']})[/{CODE}]"
|
|
813
966
|
)
|
|
814
967
|
|
|
815
|
-
|
|
816
|
-
if
|
|
817
|
-
console.print(
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
968
|
+
all_attachments = queued + auto_attachments
|
|
969
|
+
if all_attachments:
|
|
970
|
+
console.print(
|
|
971
|
+
f"[dim]→ sending {len(all_attachments)} image(s) with this prompt[/dim]"
|
|
972
|
+
)
|
|
973
|
+
parts = [{"type": "text", "text": auto_text}] + [
|
|
974
|
+
a["part"] for a in all_attachments
|
|
975
|
+
]
|
|
976
|
+
state["messages"].append({"role": "user", "content": parts})
|
|
977
|
+
# Move the queued + auto bytes from "pending" to "sent" and clear
|
|
978
|
+
# the queue. session_image_bytes is what's enforced going forward.
|
|
979
|
+
state["session_image_bytes"] = state.get("session_image_bytes", 0) + sum(
|
|
980
|
+
int(a.get("size_bytes", 0)) for a in all_attachments
|
|
981
|
+
)
|
|
822
982
|
state["pending_attachments"] = []
|
|
823
983
|
else:
|
|
824
984
|
state["messages"].append({"role": "user", "content": user_input})
|
|
@@ -845,12 +1005,48 @@ def main() -> None:
|
|
|
845
1005
|
f"[yellow]Stopped after {hopped} tool hops — "
|
|
846
1006
|
"model wasn't converging. Ask it to wrap up or revise the plan.[/yellow]"
|
|
847
1007
|
)
|
|
1008
|
+
# Breadcrumb: record the incomplete state in history so a
|
|
1009
|
+
# "continue" turn resumes the right steps instead of the
|
|
1010
|
+
# model reconstructing (or hallucinating) progress.
|
|
1011
|
+
_plan = state.get("plan")
|
|
1012
|
+
if _plan is not None and not _plan.is_complete():
|
|
1013
|
+
state["messages"].append({
|
|
1014
|
+
"role": "system",
|
|
1015
|
+
"content": (
|
|
1016
|
+
f"[Execution was paused after {hopped} tool hops "
|
|
1017
|
+
f"with the plan incomplete {_plan.summary()}. "
|
|
1018
|
+
f"Remaining steps:\n{_plan.reminder_text()}\n"
|
|
1019
|
+
"When the user asks to continue, resume these "
|
|
1020
|
+
"remaining steps. Do not claim the task is "
|
|
1021
|
+
"finished until they are done.]"
|
|
1022
|
+
),
|
|
1023
|
+
})
|
|
848
1024
|
break
|
|
849
1025
|
hopped += 1
|
|
850
1026
|
|
|
851
|
-
|
|
1027
|
+
# Re-ground the model in the current plan state on every hop.
|
|
1028
|
+
# The plan lives client-side; without this the model has to
|
|
1029
|
+
# reconstruct "what's left" from buried tool history and tends
|
|
1030
|
+
# to stop early or falsely claim completion. Injected
|
|
1031
|
+
# transiently (not persisted) so it always reflects live state
|
|
1032
|
+
# and history stays clean.
|
|
1033
|
+
turn_messages = state["messages"]
|
|
1034
|
+
_plan = state.get("plan")
|
|
1035
|
+
if _plan is not None and not _plan.is_complete():
|
|
1036
|
+
turn_messages = state["messages"] + [{
|
|
1037
|
+
"role": "system",
|
|
1038
|
+
"content": (
|
|
1039
|
+
f"[Active plan {_plan.summary()}. Steps still "
|
|
1040
|
+
f"remaining:\n{_plan.reminder_text()}\n"
|
|
1041
|
+
"Keep working through these now. Do NOT tell the "
|
|
1042
|
+
"user the task is complete, and do not treat "
|
|
1043
|
+
"starting a server as the final step, until every "
|
|
1044
|
+
"step above is done. If a step is genuinely "
|
|
1045
|
+
"impossible, mark it blocked and say why.]"
|
|
1046
|
+
),
|
|
1047
|
+
}]
|
|
852
1048
|
reply, meta = render_stream(
|
|
853
|
-
stream_chat(
|
|
1049
|
+
stream_chat(turn_messages, state["cfg"], tools=TOOLS)
|
|
854
1050
|
)
|
|
855
1051
|
cost = meta.get("cost")
|
|
856
1052
|
if cost is not None:
|
|
@@ -865,6 +1061,21 @@ def main() -> None:
|
|
|
865
1061
|
tool_calls = meta.get("tool_calls") or []
|
|
866
1062
|
if not tool_calls:
|
|
867
1063
|
state["messages"].append({"role": "assistant", "content": reply})
|
|
1064
|
+
# Flag premature completion: the model ended its turn with
|
|
1065
|
+
# plan steps still open. Surfaces the gap to the user (and
|
|
1066
|
+
# the breadcrumb above keeps it in context for "continue").
|
|
1067
|
+
_plan = state.get("plan")
|
|
1068
|
+
if _plan is not None and not _plan.is_complete():
|
|
1069
|
+
_inc = _plan.incomplete()
|
|
1070
|
+
console.print(
|
|
1071
|
+
f"[yellow]⚠ ended its turn with {len(_inc)} plan "
|
|
1072
|
+
f"step(s) not completed:[/yellow]"
|
|
1073
|
+
)
|
|
1074
|
+
for _i, _s in _inc:
|
|
1075
|
+
console.print(f"[yellow] {_i}. {_s.title}[/yellow]")
|
|
1076
|
+
console.print(
|
|
1077
|
+
"[dim] If it stopped early, tell it to continue.[/dim]"
|
|
1078
|
+
)
|
|
868
1079
|
break
|
|
869
1080
|
|
|
870
1081
|
# Model called tools — execute and loop.
|
meshapi/commands.py
CHANGED
|
@@ -71,11 +71,30 @@ def handle_command(cmd: str, state: dict) -> bool:
|
|
|
71
71
|
except AttachmentError as e:
|
|
72
72
|
console.print(f"[red]Can't attach: {e}[/red]")
|
|
73
73
|
else:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
# Per-session image budget check (SSRF + 20 MB per-image
|
|
75
|
+
# are already enforced inside load_image).
|
|
76
|
+
from .safety import SESSION_IMAGE_BYTE_CAP
|
|
77
|
+
sent = state.get("session_image_bytes", 0)
|
|
78
|
+
queued_bytes = sum(
|
|
79
|
+
int(a.get("size_bytes", 0))
|
|
80
|
+
for a in (state.get("pending_attachments") or [])
|
|
78
81
|
)
|
|
82
|
+
if sent + queued_bytes + info["size_bytes"] > SESSION_IMAGE_BYTE_CAP:
|
|
83
|
+
cap_mb = SESSION_IMAGE_BYTE_CAP // (1024 * 1024)
|
|
84
|
+
console.print(
|
|
85
|
+
f"[red]Can't attach: would exceed session image budget "
|
|
86
|
+
f"({cap_mb} MB).[/red]"
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
state.setdefault("pending_attachments", []).append({
|
|
90
|
+
"part": part,
|
|
91
|
+
"size_bytes": info["size_bytes"],
|
|
92
|
+
"name": info["name"],
|
|
93
|
+
})
|
|
94
|
+
size_kb = max(1, info["size_bytes"] // 1024)
|
|
95
|
+
console.print(
|
|
96
|
+
f"[{CODE}]📎 attached {info['name']} ({size_kb} KB, {info['mime']})[/{CODE}]"
|
|
97
|
+
)
|
|
79
98
|
|
|
80
99
|
elif name == "/clear-attach":
|
|
81
100
|
had = len(state.get("pending_attachments") or [])
|
|
@@ -98,7 +117,7 @@ def handle_command(cmd: str, state: dict) -> bool:
|
|
|
98
117
|
|
|
99
118
|
elif name == "/mode":
|
|
100
119
|
if not arg:
|
|
101
|
-
cur = state.get("mode", Mode.
|
|
120
|
+
cur = state.get("mode", Mode.DEFAULT)
|
|
102
121
|
console.print(f"[dim]Current mode: {LABELS[cur]} ({cur.value})[/dim]")
|
|
103
122
|
else:
|
|
104
123
|
try:
|
|
@@ -113,7 +132,7 @@ def handle_command(cmd: str, state: dict) -> bool:
|
|
|
113
132
|
"/clear reset conversation\n"
|
|
114
133
|
"/model <name> switch model (e.g. anthropic/claude-sonnet-4.5)\n"
|
|
115
134
|
"/route <mode> cheapest|fastest|balanced|default\n"
|
|
116
|
-
"/mode <perm>
|
|
135
|
+
"/mode <perm> default|accept-edits|auto|bypass (or shift+tab)\n"
|
|
117
136
|
"/file <path> add text file to context\n"
|
|
118
137
|
"/image <path|url> attach an image (base64) to the next prompt\n"
|
|
119
138
|
"/clear-attach drop any queued image attachments\n"
|
|
@@ -122,7 +141,13 @@ def handle_command(cmd: str, state: dict) -> bool:
|
|
|
122
141
|
"/help show this\n\n"
|
|
123
142
|
"[dim]Image paths in a prompt auto-attach: drop /path/img.png in your\n"
|
|
124
143
|
"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
|
|
144
|
+
"it as text. Multiple images per prompt are supported.\n\n"
|
|
145
|
+
"Anything you /file, /image, or that the model reads via tools is sent\n"
|
|
146
|
+
"to the Mesh API gateway and the upstream model — including file\n"
|
|
147
|
+
"contents, screenshots, and shell output. Don't attach secrets.\n"
|
|
148
|
+
"Mode auto-approvals: accept-edits auto-writes inside cwd; auto adds\n"
|
|
149
|
+
"shell commands; bypass auto-approves everything (still asks before\n"
|
|
150
|
+
"writing to ~/.ssh, /etc, rm -rf, sudo, curl|sh, etc.).[/dim]",
|
|
126
151
|
title="commands",
|
|
127
152
|
border_style="cyan",
|
|
128
153
|
))
|