prompt-copilot-cli 0.1.2__tar.gz → 0.1.5__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.
- {prompt_copilot_cli-0.1.2/prompt_copilot_cli.egg-info → prompt_copilot_cli-0.1.5}/PKG-INFO +1 -1
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/main.py +66 -8
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5/prompt_copilot_cli.egg-info}/PKG-INFO +1 -1
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/SOURCES.txt +1 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/pyproject.toml +1 -1
- prompt_copilot_cli-0.1.5/tests/test_cli_args.py +37 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_image_tool.py +2 -3
- prompt_copilot_cli-0.1.5/tests/test_multimodal_messages.py +38 -0
- prompt_copilot_cli-0.1.2/tests/test_multimodal_messages.py +0 -22
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/LICENSE +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/MANIFEST.in +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/README.md +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/README.zh-CN.md +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/dependency_links.txt +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/entry_points.txt +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/requires.txt +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/top_level.txt +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/requirements.txt +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/setup.cfg +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_command_timeout.py +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_file_tools.py +0 -0
- {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_mcp_transport.py +0 -0
|
@@ -220,6 +220,7 @@ def t(key: str, **kwargs: Any) -> str:
|
|
|
220
220
|
|
|
221
221
|
|
|
222
222
|
UI_SYSTEM_LANGUAGE = "en"
|
|
223
|
+
APPLICATION_VERSION = "0.1.5"
|
|
223
224
|
DEFAULT_SYSTEM_PROMPT = t("system_prompt")
|
|
224
225
|
WORKSPACE_DIR = ROOT / "workspace"
|
|
225
226
|
LOG_DIR = ROOT / "logs"
|
|
@@ -227,7 +228,7 @@ LOG_FILE = LOG_DIR / "agent_runtime.log"
|
|
|
227
228
|
DEFAULT_MAX_CHAT_COUNT = 5
|
|
228
229
|
CHAT_MESSAGE_MAX_COUNT = 6
|
|
229
230
|
CONFIG_SAVE_FILE_PATH = ROOT / "config.json"
|
|
230
|
-
RE_ACTION_DELAY =
|
|
231
|
+
RE_ACTION_DELAY = 6 # unit: seconds
|
|
231
232
|
TOOL_SUBPROCESS_TIMEOUT = 60 * 60 # 1 hour in seconds
|
|
232
233
|
DEFAULT_MODEL_CONFIG: dict[str, Any] = {
|
|
233
234
|
"model": "",
|
|
@@ -550,14 +551,58 @@ def safe_parse_tool_args(raw_arguments: Any) -> dict[str, Any]:
|
|
|
550
551
|
return {}
|
|
551
552
|
|
|
552
553
|
|
|
553
|
-
def build_multimodal_user_message(
|
|
554
|
+
def build_multimodal_user_message(
|
|
555
|
+
text: str,
|
|
556
|
+
image_path: str | os.PathLike[str],
|
|
557
|
+
max_bytes: int | None = None,
|
|
558
|
+
) -> dict[str, Any]:
|
|
554
559
|
path = Path(image_path).expanduser()
|
|
555
560
|
if not path.exists():
|
|
556
561
|
raise FileNotFoundError(f"Image file not found: {path}")
|
|
557
562
|
|
|
558
563
|
image_bytes = path.read_bytes()
|
|
559
|
-
encoded = base64.b64encode(image_bytes).decode("ascii")
|
|
560
564
|
mime_type = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
|
565
|
+
|
|
566
|
+
if max_bytes is None:
|
|
567
|
+
max_bytes = 120_000
|
|
568
|
+
|
|
569
|
+
if max_bytes and len(image_bytes) > max_bytes:
|
|
570
|
+
try:
|
|
571
|
+
import io
|
|
572
|
+
from PIL import Image
|
|
573
|
+
|
|
574
|
+
with Image.open(path) as img:
|
|
575
|
+
img = img.convert("RGB")
|
|
576
|
+
candidate_bytes = image_bytes
|
|
577
|
+
candidate_mime = mime_type
|
|
578
|
+
width, height = img.size
|
|
579
|
+
for scale in [1.0, 0.8, 0.6, 0.4, 0.2, 0.1, 0.05, 0.03, 0.01]:
|
|
580
|
+
resized = img.resize((max(16, int(width * scale)), max(16, int(height * scale))), Image.Resampling.LANCZOS)
|
|
581
|
+
for quality in [85, 70, 55, 40, 25, 15, 10]:
|
|
582
|
+
buffer = io.BytesIO()
|
|
583
|
+
resized.save(buffer, format="JPEG", quality=quality, optimize=True)
|
|
584
|
+
candidate_bytes = buffer.getvalue()
|
|
585
|
+
candidate_mime = "image/jpeg"
|
|
586
|
+
encoded_candidate = base64.b64encode(candidate_bytes).decode("ascii")
|
|
587
|
+
if len(encoded_candidate) <= max_bytes:
|
|
588
|
+
image_bytes = candidate_bytes
|
|
589
|
+
mime_type = candidate_mime
|
|
590
|
+
break
|
|
591
|
+
if len(base64.b64encode(candidate_bytes).decode("ascii")) <= max_bytes:
|
|
592
|
+
break
|
|
593
|
+
else:
|
|
594
|
+
tiny = img.resize((32, 32), Image.Resampling.LANCZOS)
|
|
595
|
+
buffer = io.BytesIO()
|
|
596
|
+
tiny.save(buffer, format="JPEG", quality=15, optimize=True)
|
|
597
|
+
image_bytes = buffer.getvalue()
|
|
598
|
+
mime_type = "image/jpeg"
|
|
599
|
+
if len(base64.b64encode(image_bytes).decode("ascii")) > max_bytes:
|
|
600
|
+
image_bytes = b"\x00"
|
|
601
|
+
mime_type = "image/jpeg"
|
|
602
|
+
except Exception:
|
|
603
|
+
image_bytes = image_bytes[:max(1, max_bytes // 2)]
|
|
604
|
+
|
|
605
|
+
encoded = base64.b64encode(image_bytes).decode("ascii")
|
|
561
606
|
return {
|
|
562
607
|
"role": "user",
|
|
563
608
|
"content": [
|
|
@@ -572,8 +617,8 @@ def build_multimodal_user_message(text: str, image_path: str | os.PathLike[str])
|
|
|
572
617
|
}
|
|
573
618
|
|
|
574
619
|
|
|
575
|
-
def build_multimodal_prompt_from_image(user_text: str, image_path: str | os.PathLike[str]) -> list[dict[str, Any]]:
|
|
576
|
-
return [build_multimodal_user_message(user_text, image_path)]
|
|
620
|
+
def build_multimodal_prompt_from_image(user_text: str, image_path: str | os.PathLike[str], max_bytes: int | None = None) -> list[dict[str, Any]]:
|
|
621
|
+
return [build_multimodal_user_message(user_text, image_path, max_bytes=max_bytes)]
|
|
577
622
|
|
|
578
623
|
|
|
579
624
|
def resolve_execution_cwd(cwd: Any, fallback: str | os.PathLike[str] | None = None) -> str:
|
|
@@ -1066,9 +1111,7 @@ def execute_tool_call(tool_call: Any) -> dict[str, Any]:
|
|
|
1066
1111
|
logger.info("read_image_as_base64 result: %s", result)
|
|
1067
1112
|
return result
|
|
1068
1113
|
|
|
1069
|
-
|
|
1070
|
-
encoded = base64.b64encode(image_bytes).decode("ascii")
|
|
1071
|
-
result = {"status": "ok", "content": json.dumps({"path": str(path), "base64": encoded}, ensure_ascii=False)}
|
|
1114
|
+
result = {"status": "ok", "content": json.dumps({"path": str(path), "message": "Image loaded into context for multimodal analysis."}, ensure_ascii=False)}
|
|
1072
1115
|
logger.info("read_image_as_base64 result: %s", result)
|
|
1073
1116
|
return result
|
|
1074
1117
|
|
|
@@ -1462,6 +1505,20 @@ def run_agent(client: OpenAI, model: str, system_prompt: str, session_store: Ses
|
|
|
1462
1505
|
show_stage(t("tool_call_finished"), end_calling_tips[:80])
|
|
1463
1506
|
show_tool_result(tc, result)
|
|
1464
1507
|
messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)})
|
|
1508
|
+
|
|
1509
|
+
if tc.function.name == "read_image_as_base64":
|
|
1510
|
+
try:
|
|
1511
|
+
payload = json.loads(result.get("content", "{}")) if isinstance(result.get("content"), str) else result.get("content", {})
|
|
1512
|
+
image_path = payload.get("path") if isinstance(payload, dict) else None
|
|
1513
|
+
if image_path:
|
|
1514
|
+
image_message = build_multimodal_user_message(
|
|
1515
|
+
"The user attached an image for multimodal analysis. Please inspect it carefully.",
|
|
1516
|
+
image_path,
|
|
1517
|
+
max_bytes=120_000,
|
|
1518
|
+
)
|
|
1519
|
+
messages.append(image_message)
|
|
1520
|
+
except Exception:
|
|
1521
|
+
logger.exception("Failed to append multimodal image message")
|
|
1465
1522
|
# if recorder:
|
|
1466
1523
|
# recorder.record_tool_result(tc.function.name, result)
|
|
1467
1524
|
except KeyboardInterrupt:
|
|
@@ -1594,6 +1651,7 @@ def interactive_loop(client: OpenAI, model: str, system_prompt: str, session_sto
|
|
|
1594
1651
|
|
|
1595
1652
|
def build_cli_parser() -> argparse.ArgumentParser:
|
|
1596
1653
|
parser = argparse.ArgumentParser(description=t("cli_parser_description"))
|
|
1654
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {APPLICATION_VERSION}")
|
|
1597
1655
|
parser.add_argument("-t", "--task", help=t("task_argument_help"))
|
|
1598
1656
|
parser.add_argument("-d", "--workdir", default=WORKSPACE_DIR, help=t("workdir_argument_help"))
|
|
1599
1657
|
parser.add_argument("-l", "--lang", default="en", help="language code for localization (default: en)")
|
{prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/SOURCES.txt
RENAMED
|
@@ -11,6 +11,7 @@ prompt_copilot_cli.egg-info/dependency_links.txt
|
|
|
11
11
|
prompt_copilot_cli.egg-info/entry_points.txt
|
|
12
12
|
prompt_copilot_cli.egg-info/requires.txt
|
|
13
13
|
prompt_copilot_cli.egg-info/top_level.txt
|
|
14
|
+
tests/test_cli_args.py
|
|
14
15
|
tests/test_command_timeout.py
|
|
15
16
|
tests/test_file_tools.py
|
|
16
17
|
tests/test_image_tool.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "prompt-copilot-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "A lightweight terminal-based coding agent with file tools, command execution, multimodal image support, and MCP integration."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
8
|
+
|
|
9
|
+
import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CLITests(unittest.TestCase):
|
|
13
|
+
def test_version_flag_prints_application_version_and_exits(self) -> None:
|
|
14
|
+
parser = main.build_cli_parser()
|
|
15
|
+
output = io.StringIO()
|
|
16
|
+
|
|
17
|
+
with contextlib.redirect_stdout(output):
|
|
18
|
+
with self.assertRaises(SystemExit) as exc:
|
|
19
|
+
parser.parse_args(["--version"])
|
|
20
|
+
|
|
21
|
+
self.assertEqual(exc.exception.code, 0)
|
|
22
|
+
self.assertIn(main.APPLICATION_VERSION, output.getvalue())
|
|
23
|
+
|
|
24
|
+
def test_help_flag_prints_usage_and_exits(self) -> None:
|
|
25
|
+
parser = main.build_cli_parser()
|
|
26
|
+
output = io.StringIO()
|
|
27
|
+
|
|
28
|
+
with contextlib.redirect_stdout(output):
|
|
29
|
+
with self.assertRaises(SystemExit) as exc:
|
|
30
|
+
parser.parse_args(["--help"])
|
|
31
|
+
|
|
32
|
+
self.assertEqual(exc.exception.code, 0)
|
|
33
|
+
self.assertIn("usage:", output.getvalue().lower())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
unittest.main()
|
|
@@ -24,9 +24,8 @@ class ImageToolTests(unittest.TestCase):
|
|
|
24
24
|
result = main.execute_tool_call(DummyToolCall("read_image_as_base64", {"path": str(image_path)}))
|
|
25
25
|
|
|
26
26
|
self.assertEqual(result["status"], "ok")
|
|
27
|
-
|
|
28
|
-
self.
|
|
29
|
-
self.assertTrue(payload["base64"])
|
|
27
|
+
self.assertIn("loaded into context", result["content"].lower())
|
|
28
|
+
self.assertNotIn("base64", result["content"].lower())
|
|
30
29
|
|
|
31
30
|
|
|
32
31
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
7
|
+
|
|
8
|
+
import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MultimodalMessageTests(unittest.TestCase):
|
|
12
|
+
def test_build_multimodal_user_message_with_image(self) -> None:
|
|
13
|
+
image_path = Path("tests") / "fixtures" / "sample.png"
|
|
14
|
+
message = main.build_multimodal_user_message("describe this", image_path)
|
|
15
|
+
|
|
16
|
+
self.assertEqual(message["role"], "user")
|
|
17
|
+
self.assertEqual(message["content"][0]["type"], "text")
|
|
18
|
+
self.assertEqual(message["content"][1]["type"], "image_url")
|
|
19
|
+
self.assertIn("data:image/", message["content"][1]["image_url"]["url"])
|
|
20
|
+
|
|
21
|
+
def test_build_multimodal_user_message_compresses_image(self) -> None:
|
|
22
|
+
from PIL import Image
|
|
23
|
+
|
|
24
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
25
|
+
image_path = Path(tmpdir) / "large.png"
|
|
26
|
+
large_image = Image.new("RGB", (4000, 4000), color=(255, 0, 0))
|
|
27
|
+
large_image.save(image_path)
|
|
28
|
+
original_bytes = image_path.read_bytes()
|
|
29
|
+
message = main.build_multimodal_user_message("describe this", image_path, max_bytes=5000)
|
|
30
|
+
|
|
31
|
+
payload = message["content"][1]["image_url"]["url"]
|
|
32
|
+
encoded = payload.split(",", 1)[1]
|
|
33
|
+
self.assertLess(len(encoded), 5000)
|
|
34
|
+
self.assertLess(len(encoded), len(original_bytes) * 2)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
unittest.main()
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
import unittest
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
6
|
-
|
|
7
|
-
import main
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class MultimodalMessageTests(unittest.TestCase):
|
|
11
|
-
def test_build_multimodal_user_message_with_image(self) -> None:
|
|
12
|
-
image_path = Path("tests") / "fixtures" / "sample.png"
|
|
13
|
-
message = main.build_multimodal_user_message("describe this", image_path)
|
|
14
|
-
|
|
15
|
-
self.assertEqual(message["role"], "user")
|
|
16
|
-
self.assertEqual(message["content"][0]["type"], "text")
|
|
17
|
-
self.assertEqual(message["content"][1]["type"], "image_url")
|
|
18
|
-
self.assertIn("data:image/png;base64", message["content"][1]["image_url"]["url"])
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if __name__ == "__main__":
|
|
22
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/requires.txt
RENAMED
|
File without changes
|
{prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|