Initial public release v0.0.1.alpha2
opencode-rails — production-grade Rails integration for OpenCode. Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle (idempotent ensure!/recreate!/abort! with row-level locks), a Turn orchestrator driving the Reply state machine and recovering from session-not-found, an artifact pipeline backed by ActiveStorage, sandbox seeding, and tool-display value objects for Turbo Stream broadcasts. Drop into any Rails 7.1+ app that wants production-grade OpenCode streaming without rolling boilerplate. What this version ships: - Opencode::Session (AR-coupled lifecycle, row-level locks) - Opencode::Turn (Reply state machine, session-not-found recovery) - Opencode::Exchange (one turn = one request/response unit) - Opencode::Impostor (deterministic mock for tests) - Opencode::Sandbox / SandboxFile (per-session FS scratch space) - Opencode::Transform (host-rendered artifact pipeline) - Opencode::Artifact / MessageArtifacts (ActiveStorage-backed) - Opencode::UploadedFilesPrompt (system-prompt builder) - Opencode::ToolDisplay (Turbo Stream value objects) - Opencode::ErrorReporter (pluggable adapter — Honeybadger/Sentry/etc.) - examples/rails_integration.rb — canonical wiring blueprint 53 smoke tests. CI on Ruby 3.2/3.3/3.4. Ruby >= 3.2. Runtime deps: opencode-ruby = 0.0.1.alpha2, activerecord/activestorage/activesupport >= 7.1, < 9.0. See CHANGELOG.md for the alpha1 -> alpha2 delta.
This commit is contained in:
85
lib/opencode/uploaded_files_prompt.rb
Normal file
85
lib/opencode/uploaded_files_prompt.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user