Module: Rubino::Interaction::ImageInput
- Defined in:
- lib/rubino/interaction/image_input.rb
Overview
Parses a raw CLI input line and pulls out image attachments so they can be routed to the model’s native vision slot (image_paths) instead of being sent as literal text.
Three input shapes are recognised, mirroring how Claude Code lets a user attach an image from the terminal:
1. `@path/to/pic.png` — the composer's `@` file-picker. When the picked
file is an image it becomes an attachment; a non-image `@file` is left
in the text untouched (the model reads it via the `read` tool, as
before).
2. A dropped / pasted file path — terminals insert an absolute path when
a file is dragged in, often single/double-quoted or backslash-escaped
for spaces. An image path (quoted, escaped or bare) is attached.
Only paths that (a) have a recognised image extension AND (b) exist on disk are attached; anything else is preserved verbatim in the returned text so we never silently eat a word that merely looked path-ish.
Every candidate attachment is then gated through the SAME secure-by-default attachment layer the server/run path uses (Attachments::Classify + Policy: lstat/realpath safety pipeline, max_file_bytes cap, magic-byte kind check) — the CLI used to bypass it entirely, shipping oversize/spoofed files to the provider and burning the retry budget on the permanent error (#98). A rejected candidate is consumed from the text and reported in Result#rejected so the caller can surface a clean one-line error.
Returns a Result with the cleaned text (image tokens removed, whitespace collapsed), the de-duplicated, expanded absolute image paths in order, and any policy rejections as { path:, reason: } hashes.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- AT_TOKEN =
An ‘@token`: `@` followed by a run of non-space chars. Quoting inside an `@` token isn’t a terminal convention, so we keep it simple.
/(?<![^\s])@(\S+)/- QUOTED_PATH =
A quoted path: ‘…’ or “…” (drag-drop on terminals that quote).
/(?<![^\s])(?:'([^']+)'|"([^"]+)")/- BARE_PATH =
A bare / backslash-escaped path token: a leading /, ./, ../ or ~/ then a run of non-space chars, allowing ‘\ ` escaped spaces (drag-drop default on iTerm/Terminal.app). Anchored at a word boundary so it doesn’t bite into the middle of a URL or sentence.
%r{(?<![^\s])((?:~|\.{0,2})/(?:\\.|\S)+)}
Class Method Summary collapse
-
.attachment_error(path) ⇒ Object
Gates one candidate image through the universal attachment layer — Attachments::Classify (lstat/realpath safety pipeline, max_file_bytes cap, magic-byte classification) + Policy.allow_kind? — the SAME checks the server/run path applies (#98).
-
.capture_if_image(token, original, paths, rejected) ⇒ Object
If
tokenresolves to an existing image file, record its absolute path and drop it from the text (returns “”); otherwise leave the original match (original) untouched. -
.expand(token) ⇒ Object
Normalises a raw token into an absolute filesystem path: strips backslash escapes (‘\ ` → ` `) and expands `~`/relative paths.
-
.parse(input, existing: []) ⇒ Object
Extracts image attachments from
input.
Class Method Details
.attachment_error(path) ⇒ Object
Gates one candidate image through the universal attachment layer —Attachments::Classify (lstat/realpath safety pipeline, max_file_bytes cap, magic-byte classification) + Policy.allow_kind? — the SAME checks the server/run path applies (#98). Returns a one-line human reason when the file must NOT be attached, nil when it is safe to send.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/rubino/interaction/image_input.rb', line 103 def (path) cls = Attachments::Classify.call(path) unless cls.safe if cls.reason.to_s.start_with?("exceeds max_file_bytes") return "exceeds the #{Attachments::Policy.max_file_bytes / 1_048_576} MB attachment limit" end return cls.reason end return "not a valid image (content is #{cls.mime})" unless cls.kind == :image return "image attachments are disabled by policy (allow_kinds)" unless Attachments::Policy.allow_kind?(:image) nil end |
.capture_if_image(token, original, paths, rejected) ⇒ Object
If token resolves to an existing image file, record its absolute path and drop it from the text (returns “”); otherwise leave the original match (original) untouched. original is captured by the caller before any path work, because #expand runs its own gsub and would clobber Regexp.last_match here. A candidate that LOOKS like an image but fails the attachment policy is consumed too — never shipped, never left as a path the model would chase with tools — and recorded in rejected.
86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/rubino/interaction/image_input.rb', line 86 def capture_if_image(token, original, paths, rejected) path = (token) return original unless LLM::ContentBuilder.image_file?(path) && File.file?(path) if (reason = (path)) rejected << { path: path, reason: reason } else paths << path unless paths.include?(path) end "" end |
.expand(token) ⇒ Object
Normalises a raw token into an absolute filesystem path: strips backslash escapes (‘\ ` → ` `) and expands `~`/relative paths.
120 121 122 123 124 |
# File 'lib/rubino/interaction/image_input.rb', line 120 def (token) File.(token.to_s.gsub(/\\(.)/, '\1')) rescue ArgumentError token.to_s end |
.parse(input, existing: []) ⇒ Object
Extracts image attachments from input. existing lets a caller carry forward images already attached to the pending turn (e.g. a clipboard paste) so a follow-up line’s parse adds to them rather than replacing.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/rubino/interaction/image_input.rb', line 60 def parse(input, existing: []) text = input.to_s paths = [] rejected = [] text = text.gsub(AT_TOKEN) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) } text = text.gsub(QUOTED_PATH) do token = Regexp.last_match(1) || Regexp.last_match(2) capture_if_image(token, Regexp.last_match(0), paths, rejected) end text = text.gsub(BARE_PATH) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) } Result.new( text: text.gsub(/[ \t]{2,}/, " ").strip, image_paths: (Array(existing) + paths).uniq, rejected: rejected.uniq ) end |