drydock-cli 3.0.76__tar.gz → 3.0.78__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 (99) hide show
  1. {drydock_cli-3.0.76/drydock_cli.egg-info → drydock_cli-3.0.78}/PKG-INFO +6 -1
  2. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/README.md +5 -0
  3. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/providers.py +49 -18
  4. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tools/__init__.py +43 -4
  5. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tui/app.py +8 -0
  6. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tuning.py +22 -6
  7. {drydock_cli-3.0.76 → drydock_cli-3.0.78/drydock_cli.egg-info}/PKG-INFO +6 -1
  8. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock_cli.egg-info/SOURCES.txt +1 -0
  9. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/pyproject.toml +1 -1
  10. drydock_cli-3.0.78/tests/test_viewimage.py +58 -0
  11. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_vision_input.py +20 -0
  12. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/LICENSE +0 -0
  13. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/NOTICE +0 -0
  14. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/__init__.py +0 -0
  15. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/agent.py +0 -0
  17. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/bash_safety.py +0 -0
  18. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/builtin_skills/__init__.py +0 -0
  19. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/builtin_skills/rmf-categorize.md +0 -0
  20. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/builtin_skills/rmf-control.md +0 -0
  21. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/builtin_skills/rmf-poam.md +0 -0
  22. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/builtin_skills/rmf-review.md +0 -0
  23. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/builtin_skills/stig-assess.md +0 -0
  24. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/builtin_skills/stig-remediate.md +0 -0
  25. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/cci.py +0 -0
  26. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/cli.py +0 -0
  27. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/compaction.py +0 -0
  28. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/config.py +0 -0
  29. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/detect.py +0 -0
  30. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/extract.py +0 -0
  31. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/gittools.py +0 -0
  32. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/graphrag.py +0 -0
  33. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/guards.py +0 -0
  34. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/loop_detect.py +0 -0
  35. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/mcp.py +0 -0
  36. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/rmf.py +0 -0
  37. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/rmf_graph.py +0 -0
  38. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/skills.py +0 -0
  39. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/stig.py +0 -0
  40. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tool_registry.py +0 -0
  41. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tui/__init__.py +0 -0
  42. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tui/approval.py +0 -0
  43. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tui/messages.py +0 -0
  44. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/tui/widgets.py +0 -0
  45. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock/web.py +0 -0
  46. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock_cli.egg-info/dependency_links.txt +0 -0
  47. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock_cli.egg-info/entry_points.txt +0 -0
  48. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock_cli.egg-info/requires.txt +0 -0
  49. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/drydock_cli.egg-info/top_level.txt +0 -0
  50. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/setup.cfg +0 -0
  51. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_approval.py +0 -0
  52. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_back_command.py +0 -0
  53. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_bash_output_bounding.py +0 -0
  54. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_bash_process_group.py +0 -0
  55. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_bash_safety.py +0 -0
  56. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_bash_timeout_network.py +0 -0
  57. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_cci.py +0 -0
  58. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_cli_agents.py +0 -0
  59. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_compact_command.py +0 -0
  60. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_compaction.py +0 -0
  61. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_config.py +0 -0
  62. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_config_migration.py +0 -0
  63. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_context_limit_config.py +0 -0
  64. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_detect.py +0 -0
  65. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_dispatch.py +0 -0
  66. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_e2e_connected.py +0 -0
  67. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_empty_response.py +0 -0
  68. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_extract.py +0 -0
  69. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_failure_loop.py +0 -0
  70. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_first_run_setup.py +0 -0
  71. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_gittools.py +0 -0
  72. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_graphrag.py +0 -0
  73. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_guards_and_tools.py +0 -0
  74. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_hallucinated_tools.py +0 -0
  75. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_leaked_tool_call.py +0 -0
  76. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_loop_detect.py +0 -0
  77. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_mcp.py +0 -0
  78. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_oneshot_unreachable.py +0 -0
  79. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_plan_autocontinue.py +0 -0
  80. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_providers_unreachable.py +0 -0
  81. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_read_index.py +0 -0
  82. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_rmf.py +0 -0
  83. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_rmf_graph.py +0 -0
  84. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_rmf_stig_graph.py +0 -0
  85. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_runaway_repetition.py +0 -0
  86. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_server_probe.py +0 -0
  87. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_skills.py +0 -0
  88. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_stig.py +0 -0
  89. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_stop.py +0 -0
  90. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_streaming_newlines.py +0 -0
  91. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_subagent.py +0 -0
  92. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_system_prompt_help.py +0 -0
  93. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_todo.py +0 -0
  94. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_tool_arg_parsing.py +0 -0
  95. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_tools_undo.py +0 -0
  96. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_tui.py +0 -0
  97. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_tuning.py +0 -0
  98. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_web_tools.py +0 -0
  99. {drydock_cli-3.0.76 → drydock_cli-3.0.78}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.76
3
+ Version: 3.0.78
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -59,6 +59,11 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
59
59
 
60
60
  - **Files & shell** — `Read` (with a structure index for huge files), `Write`,
61
61
  `Edit`, `Bash`, `Glob`, `Grep`.
62
+ - **Vision (multimodal)** — reference an image path in your message (a `.png`/
63
+ `.jpg` screenshot, mockup, or diagram) and it's attached for a vision-capable
64
+ model to *see*; the agent can also call the `ViewImage` tool to look at an
65
+ image it discovers on its own — describe a UI, read text off a screenshot,
66
+ debug a diagram. Works with any `--mmproj` server (e.g. llama.cpp + a vision model).
62
67
  - **Version control** — `GitStatus`, `GitDiff`, `GitLog`, `GitCommit`
63
68
  (structured + truncated; commit is local and reversible).
64
69
  - **Internet** — `WebSearch` + `WebFetch` (DuckDuckGo; offline-safe).
@@ -35,6 +35,11 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
35
35
 
36
36
  - **Files & shell** — `Read` (with a structure index for huge files), `Write`,
37
37
  `Edit`, `Bash`, `Glob`, `Grep`.
38
+ - **Vision (multimodal)** — reference an image path in your message (a `.png`/
39
+ `.jpg` screenshot, mockup, or diagram) and it's attached for a vision-capable
40
+ model to *see*; the agent can also call the `ViewImage` tool to look at an
41
+ image it discovers on its own — describe a UI, read text off a screenshot,
42
+ debug a diagram. Works with any `--mmproj` server (e.g. llama.cpp + a vision model).
38
43
  - **Version control** — `GitStatus`, `GitDiff`, `GitLog`, `GitCommit`
39
44
  (structured + truncated; commit is local and reversible).
40
45
  - **Internet** — `WebSearch` + `WebFetch` (DuckDuckGo; offline-safe).
@@ -250,16 +250,11 @@ _IMAGE_MIME = {
250
250
  }
251
251
 
252
252
 
253
- def _user_content_with_images(content):
254
- """Vision support: if the user's text references image file paths that exist
255
- on disk, attach them as OpenAI multimodal image_url blocks (works with any
256
- --mmproj-enabled server). Text-only prompts pass through unchanged as a plain
257
- string, so display / loop-detection / compaction / token-counting (which all
258
- assume string content) are untouched — the multimodal list is built ONLY here,
259
- at the API boundary."""
253
+ def detect_image_paths(content) -> list[str]:
254
+ """Image file paths referenced in a user message that exist on disk. Shared
255
+ by the multimodal attach (below) and the TUI (to confirm '📎 attached')."""
260
256
  if not isinstance(content, str) or "." not in content:
261
- return content
262
- import base64
257
+ return []
263
258
  import os
264
259
  import re
265
260
 
@@ -281,21 +276,50 @@ def _user_content_with_images(content):
281
276
  p = os.path.expanduser(raw)
282
277
  if os.path.isfile(p) and p not in seen:
283
278
  seen.append(p)
279
+ return seen
280
+
281
+
282
+ _MAX_IMAGE_BYTES = 20_000_000 # don't base64 a >20MB file into a request
283
+
284
+
285
+ def _image_url_block(path: str) -> dict | None:
286
+ """A base64 data-URL image_url block for an on-disk image, or None if it
287
+ can't be read / is too large."""
288
+ import base64
289
+ import os
290
+
291
+ try:
292
+ if os.path.getsize(path) > _MAX_IMAGE_BYTES:
293
+ return None
294
+ with open(path, "rb") as f:
295
+ b64 = base64.b64encode(f.read()).decode()
296
+ except OSError:
297
+ return None
298
+ mime = _IMAGE_MIME.get(os.path.splitext(path)[1].lower(), "image/png")
299
+ return {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}
300
+
301
+
302
+ def _content_with_images(content):
303
+ """Turn text that references on-disk image paths into a multimodal content
304
+ list ([text, image_url…]); text without resolvable images passes through
305
+ unchanged. Used for BOTH user messages (the user names an image) and
306
+ ViewImage tool results (the AGENT chose to view one) — built ONLY here at the
307
+ API boundary so display/compaction/token-counting still see plain strings."""
308
+ seen = detect_image_paths(content)
284
309
  if not seen:
285
310
  return content
286
311
  blocks: list[dict] = [{"type": "text", "text": content}]
287
312
  for p in seen:
288
- mime = _IMAGE_MIME.get(os.path.splitext(p)[1].lower(), "image/png")
289
- try:
290
- with open(p, "rb") as f:
291
- b64 = base64.b64encode(f.read()).decode()
292
- except OSError:
293
- continue
294
- blocks.append({"type": "image_url",
295
- "image_url": {"url": f"data:{mime};base64,{b64}"}})
313
+ block = _image_url_block(p)
314
+ if block:
315
+ blocks.append(block)
296
316
  return blocks if len(blocks) > 1 else content
297
317
 
298
318
 
319
+ # Back-compat alias (user-message vision).
320
+ _user_content_with_images = _content_with_images
321
+
322
+
299
323
  def messages_to_openai(messages: list, system: str) -> list:
300
324
  """Convert neutral messages to OpenAI API format."""
301
325
  # value type is mixed (str content, multimodal list content, tool_calls list)
@@ -322,10 +346,17 @@ def messages_to_openai(messages: list, system: str) -> list:
322
346
  ]
323
347
  result.append(msg)
324
348
  elif role == "tool":
349
+ content = m["content"]
350
+ # Agent-side vision: a ViewImage result names an image the agent chose
351
+ # to look at — attach it so the model SEES it (servers read images
352
+ # from tool messages too, verified). Only ViewImage, so an unrelated
353
+ # tool mentioning a .png path never balloons the request.
354
+ if m.get("name") == "ViewImage" and isinstance(content, str):
355
+ content = _content_with_images(content)
325
356
  result.append({
326
357
  "role": "tool",
327
358
  "tool_call_id": m["tool_call_id"],
328
- "content": m["content"],
359
+ "content": content,
329
360
  })
330
361
  return result
331
362
 
@@ -83,6 +83,17 @@ SCHEMAS = [
83
83
  "required": ["file_path"],
84
84
  },
85
85
  },
86
+ {
87
+ "name": "ViewImage",
88
+ "description": "Look at an image file with your vision — use this when you need to SEE a screenshot, mockup, diagram, photo, or rendered output (.png/.jpg/.jpeg/.gif/.webp/.bmp). The image is shown to you so you can describe it, read text from it, or debug it. (Reading an image with the Read tool gives binary garbage; use ViewImage instead.)",
89
+ "input_schema": {
90
+ "type": "object",
91
+ "properties": {
92
+ "path": {"type": "string", "description": "Path to the image file"},
93
+ },
94
+ "required": ["path"],
95
+ },
96
+ },
86
97
  {
87
98
  "name": "Write",
88
99
  "description": "Write content to a file, creating parent directories as needed.",
@@ -474,6 +485,33 @@ def _file_index(lines: list[str], fp: str) -> str:
474
485
  return f"{head}\nKey locations ({len(anchors)}):\n{body}"
475
486
 
476
487
 
488
+ _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
489
+ _MAX_VIEW_IMAGE_BYTES = 20_000_000
490
+
491
+
492
+ def tool_viewimage(params: dict, config: dict) -> str:
493
+ """Validate an image path and return a result that names it; the API boundary
494
+ (providers.messages_to_openai) attaches the actual image to THIS tool result
495
+ so the vision model sees it. Returns a plain error string on any problem
496
+ (never an attachable path) so a bad call can't try to attach nothing."""
497
+ raw = (params.get("path") or params.get("file_path") or "").strip()
498
+ if not raw:
499
+ return "Error: ViewImage needs a `path` to an image file."
500
+ fp = _resolve_path(raw, config)
501
+ if os.path.splitext(fp)[1].lower() not in _IMAGE_EXTS:
502
+ return (f"Error: {raw} is not a supported image. ViewImage handles "
503
+ f"{', '.join(_IMAGE_EXTS)}.")
504
+ if not os.path.isfile(fp):
505
+ return f"Error: no image file at {raw} (resolved to {fp})."
506
+ size = os.path.getsize(fp)
507
+ if size > _MAX_VIEW_IMAGE_BYTES:
508
+ return f"Error: {raw} is {size // 1_000_000}MB — too large to view (limit 20MB)."
509
+ kind = os.path.splitext(fp)[1].lstrip(".").upper()
510
+ # The absolute path in this text is what the API boundary attaches.
511
+ return (f"Loaded image {fp} ({kind}, {size // 1024 or 1}KB) — it is now visible "
512
+ "to you. Describe it, read any text in it, or use it to answer the task.")
513
+
514
+
477
515
  def tool_read(params: dict, config: dict) -> str:
478
516
  fp = _resolve_path(params["file_path"], config)
479
517
  limit = params.get("limit") # None = caller didn't specify a window
@@ -1010,7 +1048,7 @@ def tool_todo(params: dict, config: dict) -> str:
1010
1048
  # Tools a sub-agent may use. Excludes Write/Edit/todo (no silent mutations the
1011
1049
  # parent can't see) and `task` itself (no recursion). Bash is included for real
1012
1050
  # exploration (ls/cat/grep/find/git log) and is bounded by the same bash_safety.
1013
- SUBAGENT_TOOLS = ("Read", "Glob", "Grep", "Bash")
1051
+ SUBAGENT_TOOLS = ("Read", "ViewImage", "Glob", "Grep", "Bash")
1014
1052
 
1015
1053
  _SUBAGENT_SYSTEM = (
1016
1054
  "You are a focused exploration sub-agent inside Drydock. You have read-only "
@@ -1428,7 +1466,8 @@ def register_all():
1428
1466
  for schema in SCHEMAS:
1429
1467
  name = schema["name"]
1430
1468
  func = {
1431
- "Read": tool_read, "Write": tool_write, "Edit": tool_edit,
1469
+ "Read": tool_read, "ViewImage": tool_viewimage,
1470
+ "Write": tool_write, "Edit": tool_edit,
1432
1471
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
1433
1472
  "todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
1434
1473
  "Knowledge": tool_knowledge,
@@ -1441,8 +1480,8 @@ def register_all():
1441
1480
  # Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
1442
1481
  # GitCommit + GraphAdd write).
1443
1482
  read_only = name in (
1444
- "Read", "Glob", "Grep", "task", "Dispatch", "Knowledge", "GraphQuery",
1445
- "StigRules", "StigRule",
1483
+ "Read", "ViewImage", "Glob", "Grep", "task", "Dispatch", "Knowledge",
1484
+ "GraphQuery", "StigRules", "StigRule",
1446
1485
  "WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
1447
1486
  )
1448
1487
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
@@ -409,6 +409,14 @@ class DrydockApp(App):
409
409
  # running, queue this one and drain it when the current turn finishes
410
410
  # instead of dropping it on the floor.
411
411
  self._mount(UserMessage(text))
412
+ # Confirm any image attachments so the user SEES vision is active (the
413
+ # actual attach happens at the API boundary in providers).
414
+ from drydock import providers
415
+ imgs = providers.detect_image_paths(text)
416
+ if imgs:
417
+ import os as _os
418
+ names = ", ".join(_os.path.basename(p) for p in imgs)
419
+ self._info(f"📎 attached {len(imgs)} image(s) for the model to see: {names}")
412
420
  if self._busy:
413
421
  self._queue.append(text)
414
422
  self._refresh_status()
@@ -90,7 +90,13 @@ _DEFAULT_SYSTEM_PROMPT = (
90
90
  "start to lay out the plan, then START WORKING THROUGH IT IMMEDIATELY — do "
91
91
  "NOT stop to ask whether to proceed. Call `todo` again to flip a step to "
92
92
  "done and the next to in-progress as you finish each, and only stop when "
93
- "every step is done. Don't write a TODO.md file for this."
93
+ "every step is done. Don't write a TODO.md file for this.\n"
94
+ "You have VISION: when the user references an image file path (.png/.jpg/"
95
+ ".jpeg/.gif/.webp/.bmp on disk — a screenshot, mockup, diagram, or photo), it "
96
+ "is attached and you can SEE it; you can also call the `ViewImage` tool to "
97
+ "look at any image you find yourself (Read gives binary garbage for images — "
98
+ "use ViewImage). Describe it, read text from it, or debug with it — never "
99
+ "claim you cannot view images."
94
100
  )
95
101
 
96
102
  # Short, imperative prompt. Small local models do better with "act now" than
@@ -112,7 +118,13 @@ _GEMMA_SYSTEM_PROMPT = (
112
118
  "(one task per line, '[ ]'/'[~]'/'[x]'), then IMMEDIATELY start doing the "
113
119
  "steps — do NOT stop to ask whether to proceed. Call `todo` again to update "
114
120
  "it as you finish each step, and only stop when every step is done. Do not "
115
- "write a TODO.md file for this."
121
+ "write a TODO.md file for this.\n"
122
+ "You have VISION: when the user's message references an image file path "
123
+ "(.png/.jpg/.jpeg/.gif/.webp/.bmp that exists on disk — a screenshot, mockup, "
124
+ "diagram, or photo), that image is attached and you can SEE it. You can also "
125
+ "call the `ViewImage` tool to look at any image you find yourself (Read gives "
126
+ "binary garbage for images — use ViewImage). Describe it, read text from it, "
127
+ "or use it to debug — never claim you cannot view images."
116
128
  )
117
129
 
118
130
 
@@ -221,11 +233,15 @@ _DRYDOCK_COMMANDS_HELP = (
221
233
  "- Custom skills (reusable `/<name>` prompts): `/skills new <name> <prompt "
222
234
  "text>` creates one (use $ARGS in the prompt for trailing input); `/skills` "
223
235
  "lists them; then they run it as `/<name>`.\n"
236
+ "- Images / vision: the user just references an image file path in their "
237
+ "message (a .png/.jpg screenshot, mockup, or diagram on disk) and it is "
238
+ "attached for you to SEE — no command needed.\n"
224
239
  "- Other: `/model` (model/endpoint), `/cwd`, `/undo` (revert last write), "
225
- "`/back` (rewind a turn), `/compact` (shrink context), `/loop <n> <prompt>` "
226
- "(repeat a prompt), `/mcp` (connected MCP servers), `/status`, `/clear`, "
227
- "`/help`, `/quit`. Internet + git are tools you call yourself "
228
- "(WebSearch/WebFetch, GitStatus/GitDiff/GitLog/GitCommit)."
240
+ "`/back` (rewind a turn), `/compact` (shrink context), `/context` (view/set "
241
+ "the context-window budget), `/loop <n> <prompt>` (repeat a prompt), `/mcp` "
242
+ "(connected MCP servers), `/rmf` & `/stig` (NIST 800-53 / DISA STIG), "
243
+ "`/status`, `/clear`, `/help`, `/quit`. Internet + git are tools you call "
244
+ "yourself (WebSearch/WebFetch, GitStatus/GitDiff/GitLog/GitCommit)."
229
245
  )
230
246
 
231
247
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.76
3
+ Version: 3.0.78
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -59,6 +59,11 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
59
59
 
60
60
  - **Files & shell** — `Read` (with a structure index for huge files), `Write`,
61
61
  `Edit`, `Bash`, `Glob`, `Grep`.
62
+ - **Vision (multimodal)** — reference an image path in your message (a `.png`/
63
+ `.jpg` screenshot, mockup, or diagram) and it's attached for a vision-capable
64
+ model to *see*; the agent can also call the `ViewImage` tool to look at an
65
+ image it discovers on its own — describe a UI, read text off a screenshot,
66
+ debug a diagram. Works with any `--mmproj` server (e.g. llama.cpp + a vision model).
62
67
  - **Version control** — `GitStatus`, `GitDiff`, `GitLog`, `GitCommit`
63
68
  (structured + truncated; commit is local and reversible).
64
69
  - **Internet** — `WebSearch` + `WebFetch` (DuckDuckGo; offline-safe).
@@ -91,6 +91,7 @@ tests/test_tool_arg_parsing.py
91
91
  tests/test_tools_undo.py
92
92
  tests/test_tui.py
93
93
  tests/test_tuning.py
94
+ tests/test_viewimage.py
94
95
  tests/test_vision_input.py
95
96
  tests/test_web_tools.py
96
97
  tests/test_xccdf.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.76"
10
+ version = "3.0.78"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,58 @@
1
+ """Agent-side vision: the ViewImage tool + its API-boundary attachment.
2
+ The agent can choose to SEE an image it discovers (not just user-referenced
3
+ ones); the image rides back on the ViewImage tool result."""
4
+ from __future__ import annotations
5
+
6
+ import struct
7
+ import zlib
8
+
9
+ import drydock.tools # noqa: F401 — triggers tool registration
10
+ from drydock import tool_registry as reg
11
+ from drydock.providers import messages_to_openai
12
+
13
+
14
+ def _png(path):
15
+ w = h = 8
16
+ raw = b"".join(b"\x00" + bytes([10, 20, 200]) * w for _ in range(h))
17
+ def ch(t, d):
18
+ c = t + d
19
+ return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
20
+ path.write_bytes(b"\x89PNG\r\n\x1a\n"
21
+ + ch(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
22
+ + ch(b"IDAT", zlib.compress(raw)) + ch(b"IEND", b""))
23
+ return str(path)
24
+
25
+
26
+ def test_viewimage_validates(tmp_path):
27
+ _png(tmp_path / "a.png")
28
+ cfg = {"cwd": str(tmp_path)}
29
+ assert "now visible to you" in reg.execute("ViewImage", {"path": "a.png"}, cfg)
30
+ assert "no image file" in reg.execute("ViewImage", {"path": "missing.png"}, cfg)
31
+ assert "not a supported image" in reg.execute("ViewImage", {"path": str(tmp_path / "x.txt")}, cfg)
32
+ assert "needs a `path`" in reg.execute("ViewImage", {}, cfg)
33
+
34
+
35
+ def test_viewimage_is_read_only_and_subagent_visible():
36
+ td = reg.get("ViewImage")
37
+ assert td.read_only is True
38
+ from drydock.tools import SUBAGENT_TOOLS
39
+ assert "ViewImage" in SUBAGENT_TOOLS
40
+
41
+
42
+ def test_viewimage_result_becomes_multimodal(tmp_path):
43
+ _png(tmp_path / "shot.png")
44
+ cfg = {"cwd": str(tmp_path)}
45
+ res = reg.execute("ViewImage", {"path": "shot.png"}, cfg)
46
+ out = messages_to_openai([{"role": "tool", "tool_call_id": "c1",
47
+ "name": "ViewImage", "content": res}], "sys")
48
+ content = out[-1]["content"]
49
+ assert isinstance(content, list)
50
+ assert any(b.get("type") == "image_url" for b in content)
51
+ assert content[0]["type"] == "text"
52
+
53
+
54
+ def test_non_viewimage_tool_with_png_path_stays_text(tmp_path):
55
+ img = _png(tmp_path / "shot.png")
56
+ out = messages_to_openai([{"role": "tool", "tool_call_id": "c2",
57
+ "name": "Grep", "content": f"found {img} in code"}], "sys")
58
+ assert isinstance(out[-1]["content"], str) # no accidental image ballooning
@@ -82,3 +82,23 @@ def test_image_path_in_backticks_and_punctuation(tmp_path):
82
82
  out = _user_content_with_images(prompt)
83
83
  assert isinstance(out, list), f"not attached: {prompt!r}"
84
84
  assert any(b.get("type") == "image_url" for b in out), prompt
85
+
86
+
87
+ def test_detect_image_paths_shared_helper(tmp_path):
88
+ from drydock.providers import detect_image_paths
89
+ img = _write_png(tmp_path / "shot.png")
90
+ # plain, quoted, backticked, and trailing-punctuation forms all resolve
91
+ assert detect_image_paths(f"look at {img}") == [img]
92
+ assert detect_image_paths(f'see "{img}".') == [img]
93
+ assert detect_image_paths(f"debug `{img}`,") == [img]
94
+ # nonexistent path or text-only → nothing attached
95
+ assert detect_image_paths(f"{tmp_path}/nope.png") == []
96
+ assert detect_image_paths("no images here, just prose") == []
97
+ # de-dupes repeats
98
+ assert detect_image_paths(f"{img} and again {img}") == [img]
99
+
100
+
101
+ def test_vision_in_system_prompt():
102
+ from drydock.tuning import system_prompt_for_model
103
+ assert "VISION" in system_prompt_for_model("gemma4")
104
+ assert "VISION" in system_prompt_for_model("some-other-model")
File without changes
File without changes
File without changes