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.
Files changed (22) hide show
  1. {prompt_copilot_cli-0.1.2/prompt_copilot_cli.egg-info → prompt_copilot_cli-0.1.5}/PKG-INFO +1 -1
  2. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/main.py +66 -8
  3. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5/prompt_copilot_cli.egg-info}/PKG-INFO +1 -1
  4. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/SOURCES.txt +1 -0
  5. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/pyproject.toml +1 -1
  6. prompt_copilot_cli-0.1.5/tests/test_cli_args.py +37 -0
  7. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_image_tool.py +2 -3
  8. prompt_copilot_cli-0.1.5/tests/test_multimodal_messages.py +38 -0
  9. prompt_copilot_cli-0.1.2/tests/test_multimodal_messages.py +0 -22
  10. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/LICENSE +0 -0
  11. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/MANIFEST.in +0 -0
  12. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/README.md +0 -0
  13. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/README.zh-CN.md +0 -0
  14. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/dependency_links.txt +0 -0
  15. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/entry_points.txt +0 -0
  16. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/requires.txt +0 -0
  17. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/prompt_copilot_cli.egg-info/top_level.txt +0 -0
  18. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/requirements.txt +0 -0
  19. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/setup.cfg +0 -0
  20. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_command_timeout.py +0 -0
  21. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_file_tools.py +0 -0
  22. {prompt_copilot_cli-0.1.2 → prompt_copilot_cli-0.1.5}/tests/test_mcp_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prompt-copilot-cli
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: A lightweight terminal-based coding agent with file tools, command execution, multimodal image support, and MCP integration.
5
5
  Author: Jason Li
6
6
  License-Expression: MIT
@@ -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 = 5 # unit: seconds
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(text: str, image_path: str | os.PathLike[str]) -> dict[str, Any]:
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
- image_bytes = path.read_bytes()
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)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prompt-copilot-cli
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: A lightweight terminal-based coding agent with file tools, command execution, multimodal image support, and MCP integration.
5
5
  Author: Jason Li
6
6
  License-Expression: MIT
@@ -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.2"
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
- payload = main.json.loads(result["content"])
28
- self.assertTrue(Path(payload["path"]).exists())
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()