Eleven source files moved from ajent-rails:lib/opencode/rails/ to
opencode-rails:lib/opencode/ (flat layout — modules are Opencode::*, not
Opencode::Rails::*; matches opencode-ruby).
artifact.rb 63 LOC
exchange.rb 77 LOC
impostor.rb 48 LOC
message_artifacts.rb 133 LOC
sandbox_file.rb 81 LOC
sandbox.rb 71 LOC
session.rb 168 LOC
tool_display.rb 423 LOC
transform.rb 77 LOC
turn.rb 642 LOC
uploaded_files_prompt.rb 85 LOC
----
total 1,868 LOC
Surgical Rails strips:
exchange.rb:
Rails.event.notify(name, payload)
-> Opencode::Instrumentation.instrument(name, payload) { }
message_artifacts.rb (1 call), turn.rb (6 calls):
Rails.error.report(error, **opts)
-> Opencode::ErrorReporter.report(error, **opts)
Comments/docstrings referencing Rails.error.report / Rails.event left
in place — they document how to wire the host adapter.
ActiveSupport core_ext requires expanded in lib/opencode-rails.rb to
cover Numeric#seconds, Hash#deep_stringify_keys, String#squish/truncate,
String#demodulize. Bundle install + smoke load confirms all 12
gem-provided constants resolve cleanly.
86 lines
3.2 KiB
Ruby
86 lines
3.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Opencode
|
|
# The prompt body to send to an OpenCode agent when the user attached
|
|
# files: the user's text plus an instruction block naming each file by
|
|
# its sandboxed filename so the agent can read it with the `read` tool.
|
|
#
|
|
# Two outputs, both explicit:
|
|
#
|
|
# text — the prompt body to pass to send_message_async
|
|
# sandbox_file_names — map of sandbox_name => original filename,
|
|
# used by ReplyStream to show the user a
|
|
# recognizable name when the agent reads the
|
|
# file back.
|
|
#
|
|
# Previously this work lived in `Opencode::SandboxFiles`, an ActiveSupport
|
|
# concern that mutated a hidden `@sandbox_file_names` instance variable on
|
|
# the including job. ReplyStream then read that ivar back through a
|
|
# closure. State across class boundaries via shared mutable ivars is the
|
|
# kind of Sandi-smelly action-at-a-distance that breaks the moment
|
|
# someone forgets the contract. This value object replaces that with two
|
|
# named return values.
|
|
#
|
|
# Side effect, unchanged from the concern: file bytes are copied from
|
|
# ActiveStorage into the per-user OpenCode sandbox directory so the
|
|
# agent can read them with the `read` tool. The copy is path-escape
|
|
# guarded (the cleanpath of the destination must start with the
|
|
# sandbox dir prefix, no symlink trickery).
|
|
class UploadedFilesPrompt
|
|
attr_reader :text, :sandbox_file_names
|
|
|
|
def initialize(user_message:, sandbox_path:, sandbox_name_for:)
|
|
@user_message = user_message
|
|
@sandbox_path = sandbox_path
|
|
@sandbox_name_for = sandbox_name_for
|
|
@sandbox_file_names = {}
|
|
@text = build_text
|
|
end
|
|
|
|
private
|
|
|
|
def build_text
|
|
raw = @user_message.content.to_s
|
|
return raw unless @user_message.files.attached?
|
|
|
|
file_instructions = @user_message.files.map do |file|
|
|
sandbox_file = copy_to_sandbox(file)
|
|
@sandbox_file_names[sandbox_file.sandbox_name] = file.filename.to_s
|
|
"#{file.filename} -> #{sandbox_file.sandbox_name} (#{file.content_type}, #{file.byte_size} bytes)"
|
|
end
|
|
|
|
[
|
|
raw,
|
|
"",
|
|
"The user uploaded #{file_instructions.size} file(s). Read each file thoroughly, then consult your reference materials and verify any legal claims before responding:",
|
|
*file_instructions
|
|
].join("\n").strip
|
|
end
|
|
|
|
def copy_to_sandbox(file)
|
|
FileUtils.mkdir_p(@sandbox_path)
|
|
|
|
sandbox_name = @sandbox_name_for.call(file)
|
|
dest = File.join(@sandbox_path, sandbox_name)
|
|
|
|
resolved = Pathname.new(dest).cleanpath.to_s
|
|
unless resolved.start_with?(@sandbox_path)
|
|
raise ArgumentError, "Filename escapes sandbox: #{sandbox_name}"
|
|
end
|
|
|
|
File.open(dest, "wb") { |f| f.write(file.download) }
|
|
Placement.new(sandbox_name, dest)
|
|
end
|
|
|
|
# Tiny value pair returned by copy_to_sandbox: the canonical filename
|
|
# the agent should read by, and the on-disk path the file ended up at.
|
|
# Internal to UploadedFilesPrompt — the caller (UploadedFilesPrompt
|
|
# itself) only needs the sandbox_name to embed in the prompt text.
|
|
Placement = Struct.new(:sandbox_name, :path) do
|
|
def to_s
|
|
path
|
|
end
|
|
end
|
|
end
|
|
end
|