opencode-ruby 0.0.1.alpha2

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5c85499ae01e9b85854d1738508f079c8e4b41d00d926f7c81cd537db2a30474
4
+ data.tar.gz: 9034a4c5697390f12f2c1d4c0566de11f1e44321c9f2497bc9b8c54d514e43fb
5
+ SHA512:
6
+ metadata.gz: 8a73ddf71df4c272aa3676cdddd5deb9d0db71a10466f4f355df6f9e63ada55a9231773df56f74cf7c44544069bdfc5fa0be82ea5dc8c89732192f87d4bc980d
7
+ data.tar.gz: e8015d9f32e8f3e1712e1cdf21824eacb7f26f3244831d20c8bfdff513ebb8d692afa2360aaa90450625cf935a0eb744fed16a62c7e3ed2b8b5f421819fd288f
data/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1.alpha2 — 2026-05-20
4
+
5
+ ### Added
6
+
7
+ - `Opencode::Instrumentation.notify(name, payload)` — fire-and-forget
8
+ emission for point-in-time events that don't need duration measurement
9
+ (apply_patch.artifacts_dropped, session.recreated, etc.). Adapter
10
+ receives an empty block so AS::Notifications-shaped sinks see a
11
+ zero-duration event. Complements the existing block-form
12
+ `.instrument(name, payload) { ... }`.
13
+
14
+ ### Why
15
+
16
+ The block-form `.instrument(name, payload) { }` with an empty block was
17
+ awkward at fire-and-forget call sites in opencode-rails. Two named
18
+ verbs (`instrument` for wrap-a-block, `notify` for fire-and-forget)
19
+ match the host-side mental model and read better at the call site.
20
+
21
+ ## 0.0.1.alpha1 — Unreleased
22
+
23
+ First public alpha. HTTP + SSE client for OpenCode REST API.
24
+
25
+ ### What's in
26
+
27
+ - `Opencode::Client` — Net::HTTP-based HTTP client with SSE streaming + automatic reconnection.
28
+ - `#create_session(title:, permissions:)`, `#get_messages(session_id)`, `#list_sessions`, `#delete_session(id)`, `#abort_session(id)`.
29
+ - `#send_message(session_id, text, model:, ...)` — synchronous send-and-poll.
30
+ - `#send_message_async(session_id, text, ...)` — async send.
31
+ - `#stream(session_id, text, ...) { |part| ... } → Opencode::Reply::Result` — **the headline.** Block-form streaming with internal Reply accumulation and final-exchange merge.
32
+ - `#stream_events(session_id:, ...) { |event| ... }` — lower-level SSE event firehose for power users.
33
+ - `#reply_question(request_id:, answers:)` / `#reply_permission(request_id:, reply:)` — answer interactive prompts.
34
+ - `Opencode::Reply` — live state machine accumulating SSE events into the assistant's reply. Documented observer protocol (`Opencode::ReplyObserver`).
35
+ - `Opencode::Reply::Result` — typed Struct value object returned by `Client#stream` and `Reply#result`. Fields: `:parts_json`, `:full_text`, `:reasoning_text`, `:tool_parts`.
36
+ - `Opencode::Instrumentation` — pluggable adapter (default no-op). Plug in `ActiveSupport::Notifications`, OpenTelemetry, stdout, etc.
37
+ - `Opencode::ResponseParser`, `Opencode::ToolPart`, `Opencode::PartSource`, `Opencode::Todo` — wire-format helpers used by `Reply` and reusable by callers building their own SSE handling.
38
+ - `Opencode::Prompts` — per-Reply registry of pending question/permission prompts (used by `Reply` internally; exposed for callers that need to peek).
39
+ - `Opencode::Tracer` — callable that prefixes event names before forwarding to a host emitter.
40
+ - Error hierarchy: `Opencode::Error` and seven subclasses (`ConnectionError`, `TimeoutError`, `SessionNotFoundError`, `StaleSessionError`, `IdleStreamError`, `ServerError`, `BadRequestError`).
41
+
42
+ ### What's out
43
+
44
+ - ActiveRecord-backed session lifecycle, `acts_as_opencode_session`, generators — deferred to `opencode-rails` if external demand materializes. See `examples/conversation_recipe.rb` for the canonical Rails wiring pattern.
45
+ - Multi-tenant per-user Docker container orchestration — application glue, not a gem's concern.
46
+
47
+ ### Compatibility
48
+
49
+ - Ruby ≥ 3.2
50
+ - OpenCode server ≥ 1.15 (tested against the message bus schema in `packages/opencode/src/session/message-v2.ts`)
51
+ - Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem.
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ajaynomics
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # opencode-ruby
2
+
3
+ Idiomatic Ruby client for [OpenCode](https://opencode.ai). Block-form streaming, value-object responses, automatic SSE reconnection.
4
+
5
+ ```ruby
6
+ require "opencode-ruby"
7
+
8
+ client = Opencode::Client.new(base_url: "http://localhost:4096")
9
+ session = client.create_session(title: "My session")
10
+
11
+ reply = client.stream(session[:id], "Explain monads in two sentences.") do |part|
12
+ print part["content"] if part["type"] == "text"
13
+ end
14
+
15
+ puts
16
+ puts reply.full_text
17
+ puts "(#{reply.tool_parts.size} tool calls, #{reply.parts_json.size} parts total)"
18
+ ```
19
+
20
+ Three lines of setup, four lines of work. Block fires every time a part appears, grows, finalizes, or (for tool calls) advances state. The final return value is a typed `Opencode::Reply::Result` you can persist or inspect.
21
+
22
+ ## Install
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "opencode-ruby"
27
+ ```
28
+
29
+ Or:
30
+
31
+ ```sh
32
+ gem install opencode-ruby
33
+ ```
34
+
35
+ Then `require "opencode-ruby"`.
36
+
37
+ ## Configuration
38
+
39
+ ```ruby
40
+ client = Opencode::Client.new(
41
+ base_url: "http://localhost:4096", # or ENV["OPENCODE_BASE_URL"]
42
+ password: "secret", # or ENV["OPENCODE_SERVER_PASSWORD"]
43
+ timeout: 120 # or ENV["OPENCODE_TIMEOUT"], seconds
44
+ )
45
+ ```
46
+
47
+ Multi-tenant apps construct multiple clients with different `base_url`s — each `Opencode::Client` holds its own Net::HTTP connection, no shared state.
48
+
49
+ ## Core API
50
+
51
+ ### Streaming (the headline)
52
+
53
+ ```ruby
54
+ reply = client.stream(session_id, "What's 2 + 2?") do |part|
55
+ case part["type"]
56
+ when "text" then print part["content"]
57
+ when "reasoning" then # ignore, or render in a separate UI
58
+ when "tool" then puts " [tool: #{part['tool']} → #{part['status']}]"
59
+ end
60
+ end
61
+
62
+ reply.full_text # => "2 + 2 = 4."
63
+ reply.tool_parts # => array of terminal tool-call parts
64
+ reply.reasoning_text # => the model's hidden reasoning, if any
65
+ reply.parts_json # => the full ordered parts array, ready for persistence
66
+ ```
67
+
68
+ ### Synchronous send (no streaming)
69
+
70
+ ```ruby
71
+ result = client.send_message(session_id, "Quick yes/no: is Ruby fun?")
72
+ # result is the OpenCode response hash; see API docs for fields.
73
+ ```
74
+
75
+ ### Lower-level event firehose
76
+
77
+ If you need raw SSE events (every server tick, todo update, prompt asked/replied), use `stream_events` directly:
78
+
79
+ ```ruby
80
+ client.stream_events(session_id: session_id) do |event|
81
+ puts event[:type] # "message.part.delta", "todo.updated", "session.idle", ...
82
+ end
83
+ ```
84
+
85
+ ### Interactive prompts
86
+
87
+ When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via:
88
+
89
+ ```ruby
90
+ client.reply_question(request_id: "que_...", answers: [["yes"]])
91
+ client.reply_permission(request_id: "per_...", reply: "always")
92
+ ```
93
+
94
+ ## Error model
95
+
96
+ Every method that hits the network raises `Opencode::Error` (or a subclass) on failure. Catch the parent or the specific subclass:
97
+
98
+ ```ruby
99
+ begin
100
+ client.health
101
+ rescue Opencode::ConnectionError # server unreachable
102
+ rescue Opencode::TimeoutError # client-side timeout
103
+ rescue Opencode::SessionNotFoundError # 404 on a session
104
+ rescue Opencode::StaleSessionError # session.idle never arrived
105
+ rescue Opencode::IdleStreamError # mid-turn SSE wedge
106
+ rescue Opencode::ServerError # 5xx
107
+ rescue Opencode::BadRequestError # 4xx other than 404
108
+ rescue Opencode::Error # catch-all
109
+ end
110
+ ```
111
+
112
+ ## Instrumentation
113
+
114
+ Want to see what the gem is doing? Plug in an adapter. Default behaviour is silent no-op — the gem ships zero opinion about your observability stack.
115
+
116
+ ```ruby
117
+ # stdout for debugging:
118
+ Opencode::Instrumentation.adapter = ->(name, payload, &block) {
119
+ puts "[#{name}] #{payload.inspect}"
120
+ block.call
121
+ }
122
+
123
+ # ActiveSupport::Notifications in a Rails app:
124
+ Opencode::Instrumentation.adapter = ->(name, payload, &block) {
125
+ ActiveSupport::Notifications.instrument(name, payload, &block)
126
+ }
127
+ ```
128
+
129
+ Event names emitted today:
130
+
131
+ | Event | Payload |
132
+ |---|---|
133
+ | `opencode.request` | `:method`, `:path` |
134
+
135
+ ## Want this in a Rails app?
136
+
137
+ See [`examples/conversation_recipe.rb`](examples/conversation_recipe.rb) for a ~60-line plain-ActiveRecord blueprint covering session lifecycle (`with_lock`, `update_columns` mid-stream snapshots, CAS-safe finalize). Drop it into your app and adapt.
138
+
139
+ If enough Rails developers do that and want it as a one-liner, we'll ship `opencode-rails` with `acts_as_opencode_session`. **File an issue if that's you** — your issue is the signal.
140
+
141
+ ## Position against `opencode_client`
142
+
143
+ Want every OpenCode endpoint auto-generated from the OpenAPI spec? Use [`opencode_client`](https://rubygems.org/gems/opencode_client). This gem is the hand-rolled idiomatic alternative — smaller surface, opinionated defaults, block-form streaming. Pick whichever fits how you want to write Ruby.
144
+
145
+ ## Compatibility
146
+
147
+ - Ruby ≥ 3.2
148
+ - OpenCode server ≥ 1.15
149
+ - Runtime dependency: `activesupport (>= 6.1)` — *not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.).
150
+
151
+ ## Development
152
+
153
+ ```sh
154
+ bundle install
155
+ bundle exec rake test
156
+ ```
157
+
158
+ 12-test smoke covers Client end-to-end against WebMock-stubbed OpenCode endpoints.
159
+
160
+ ## License
161
+
162
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails integration recipe — copy + adapt.
4
+ #
5
+ # This is NOT part of opencode-ruby. It's the canonical pattern showing
6
+ # how to wire the gem's primitives into a Rails ActiveRecord app. Drop
7
+ # this file into your `app/models/` (rename it), adapt the schema, and
8
+ # you have a working block-streaming chat with row-locked session
9
+ # lifecycle and CAS-safe finalize.
10
+ #
11
+ # What this recipe demonstrates:
12
+ #
13
+ # 1. Schema (below in a comment) — the migration you'll need
14
+ # 2. Session lifecycle — idempotent ensure! with with_lock
15
+ # 3. Mid-stream parts persistence via update_columns (bypasses
16
+ # AR callbacks so Turbo broadcasts don't fire per-part)
17
+ # 4. CAS-safe finalize — concurrent cancel wins
18
+ # 5. Recovery from SessionNotFoundError — recreate once + retry
19
+ #
20
+ # If you want this as a one-liner (`acts_as_opencode_session`), open
21
+ # an issue on the repo. The gem ships this recipe instead of a concern
22
+ # because the right shape depends on the host app's conventions — and
23
+ # shipping a half-built concern is worse than shipping a clear
24
+ # blueprint you can adapt.
25
+ #
26
+ # Suggested schema (adapt naming to your domain):
27
+ #
28
+ # create_table :conversations do |t|
29
+ # t.references :user, null: false, foreign_key: true
30
+ # t.string :title
31
+ # t.string :opencode_session_id
32
+ # t.timestamps
33
+ # t.index :opencode_session_id, unique: true,
34
+ # where: "opencode_session_id IS NOT NULL" # partial unique
35
+ # end
36
+ #
37
+ # create_table :messages do |t|
38
+ # t.references :conversation, null: false, foreign_key: true
39
+ # t.string :role, null: false # "user" or "assistant"
40
+ # t.integer :status, null: false, default: 0 # see enum below
41
+ # t.text :content, null: false, default: ""
42
+ # t.json :parts_json, null: false, default: []
43
+ # t.json :tool_calls_json, null: false, default: []
44
+ # t.decimal :cost, precision: 10, scale: 6
45
+ # t.integer :input_tokens
46
+ # t.integer :output_tokens
47
+ # t.timestamps
48
+ # end
49
+
50
+ class Conversation < ApplicationRecord
51
+ belongs_to :user
52
+ has_many :messages, dependent: :destroy
53
+
54
+ # Returns the OpenCode session id for this conversation, creating one
55
+ # if needed. Idempotent. Race-safe via row-lock + double-check.
56
+ def ensure_opencode_session!(client)
57
+ return opencode_session_id if opencode_session_id.present?
58
+
59
+ with_lock do
60
+ return opencode_session_id if opencode_session_id.present?
61
+ session = client.create_session(title: title)
62
+ update!(opencode_session_id: session[:id] || session["id"])
63
+ end
64
+ opencode_session_id
65
+ rescue ActiveRecord::RecordNotUnique
66
+ # Another worker raced past the partial unique index. Loser reloads.
67
+ reload
68
+ opencode_session_id
69
+ end
70
+
71
+ # Replace a stale upstream session. Used by SessionNotFoundError
72
+ # recovery in the streaming job below.
73
+ def recreate_opencode_session!(client)
74
+ pre_id = opencode_session_id
75
+ with_lock do
76
+ return opencode_session_id if opencode_session_id.present? && opencode_session_id != pre_id
77
+ session = client.create_session(title: title)
78
+ update!(opencode_session_id: session[:id] || session["id"])
79
+ end
80
+ opencode_session_id
81
+ end
82
+ end
83
+
84
+ class Message < ApplicationRecord
85
+ belongs_to :conversation
86
+
87
+ enum status: { pending: 0, streaming: 1, completed: 2, cancelled: 3, errored: 4 }
88
+ end
89
+
90
+ # The streaming job. Compose Opencode::Client + ActiveRecord; that's it.
91
+ class GenerateAssistantReplyJob < ApplicationJob
92
+ def perform(message_id, user_prompt)
93
+ message = Message.find(message_id)
94
+ return unless message.pending?
95
+
96
+ client = Opencode::Client.new(
97
+ base_url: ENV.fetch("OPENCODE_BASE_URL"),
98
+ password: ENV["OPENCODE_SERVER_PASSWORD"]
99
+ )
100
+
101
+ session_id = message.conversation.ensure_opencode_session!(client)
102
+ message.update!(status: :streaming)
103
+
104
+ attempted_recreate = false
105
+ begin
106
+ reply = client.stream(session_id, user_prompt) do |part|
107
+ # Mid-stream snapshot: update_columns bypasses AR callbacks so
108
+ # an after_update_commit broadcasts_refreshes_to(conversation)
109
+ # doesn't fire per-part and clobber per-part Turbo broadcasts
110
+ # you might be doing separately. The final write below uses
111
+ # update! to fire callbacks deliberately.
112
+ message.update_columns(
113
+ parts_json: reply_parts_so_far(part, message),
114
+ updated_at: Time.current
115
+ )
116
+ end
117
+
118
+ # CAS-safe finalize: only land the final state if no concurrent
119
+ # cancel got there first.
120
+ message.with_lock do
121
+ return unless message.reload.pending? || message.streaming?
122
+ message.update!(
123
+ status: :completed,
124
+ content: reply.full_text,
125
+ parts_json: reply.parts_json,
126
+ tool_calls_json: reply.tool_parts
127
+ )
128
+ end
129
+ rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError
130
+ raise if attempted_recreate
131
+ message.conversation.recreate_opencode_session!(client)
132
+ attempted_recreate = true
133
+ retry
134
+ end
135
+ rescue StandardError => e
136
+ message&.update!(status: :errored, content: "An error occurred: #{e.message.truncate(200)}")
137
+ end
138
+
139
+ private
140
+
141
+ # Builds the parts array up to (and including) the current part by
142
+ # poking the gem's internal Reply state. In practice you'd capture
143
+ # the Reply instance from the block via a closure, OR derive from
144
+ # `part` if you only need the latest part.
145
+ def reply_parts_so_far(part, message)
146
+ parts = (message.parts_json || []).dup
147
+ # Trivial dedup: replace or append by part id, if your wire-format
148
+ # includes one. For real merge logic, lift Opencode::Reply's
149
+ # part_index_by_id / append_part pattern.
150
+ parts << part unless parts.any? { |existing| existing["id"] == part["id"] }
151
+ parts
152
+ end
153
+ end