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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +18 -0
- data/README.md +162 -0
- data/examples/conversation_recipe.rb +153 -0
- data/lib/opencode/client.rb +564 -0
- data/lib/opencode/error.rb +28 -0
- data/lib/opencode/instrumentation.rb +76 -0
- data/lib/opencode/part_source.rb +62 -0
- data/lib/opencode/prompts.rb +87 -0
- data/lib/opencode/reply.rb +549 -0
- data/lib/opencode/reply_observer.rb +101 -0
- data/lib/opencode/response_parser.rb +169 -0
- data/lib/opencode/todo.rb +43 -0
- data/lib/opencode/tool_part.rb +152 -0
- data/lib/opencode/tracer.rb +50 -0
- data/lib/opencode/version.rb +5 -0
- data/lib/opencode-ruby.rb +26 -0
- data/opencode-ruby.gemspec +45 -0
- metadata +129 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
# The canonical observer protocol for Opencode::Reply — every event
|
|
5
|
+
# Reply dispatches, documented in one place, with safe no-op defaults.
|
|
6
|
+
#
|
|
7
|
+
# Include this module in a reply-stream class to get two things:
|
|
8
|
+
#
|
|
9
|
+
# 1. **Compile-time checklist.** Override only the callbacks you care
|
|
10
|
+
# about; the rest inherit a no-op. Forgetting to handle a new event
|
|
11
|
+
# never crashes the stream.
|
|
12
|
+
# 2. **Protocol documentation that can't rot.** The signatures here are
|
|
13
|
+
# the contract. If Reply's dispatch shape ever drifts, every observer
|
|
14
|
+
# using this module updates in lockstep.
|
|
15
|
+
#
|
|
16
|
+
# Callbacks are duck-typed in Reply — features may choose not to
|
|
17
|
+
# include this module and implement the methods directly, but then
|
|
18
|
+
# they lose the two benefits above.
|
|
19
|
+
#
|
|
20
|
+
# Every callback takes keyword arguments, so adding a new keyword later
|
|
21
|
+
# only requires existing observers to add `**_` if they want to opt out
|
|
22
|
+
# of breakage.
|
|
23
|
+
module ReplyObserver
|
|
24
|
+
# A new part was appended to the reply's parts list.
|
|
25
|
+
def part_added(part:, index:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# An existing part's content grew by a delta (streaming text or
|
|
29
|
+
# reasoning).
|
|
30
|
+
def part_changed(part:, index:, delta:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# An existing part's content was rewritten to the authoritative
|
|
34
|
+
# value from part.updated. Fires unconditionally when a part closes
|
|
35
|
+
# so throttled observers can flush, regardless of whether content
|
|
36
|
+
# actually diverged from what deltas accumulated.
|
|
37
|
+
def part_finalized(part:, index:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A tool part transitioned status (pending → running → completed/error),
|
|
41
|
+
# or its state payload (title/input/error) changed.
|
|
42
|
+
def tool_progressed(part:, index:, status:, raw:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A step boundary with usage info. `tokens` is the raw tokens hash
|
|
46
|
+
# from the step-finish part (keys: :input, :output, :reasoning, :cache).
|
|
47
|
+
def step_finished(cost:, tokens:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The upstream session is retrying an LLM call (e.g., provider
|
|
51
|
+
# rate-limit backoff). Attempt is nullable; message is a short
|
|
52
|
+
# reason string.
|
|
53
|
+
def session_retried(attempt:, message:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A session-level error surfaced. Text is a human-readable summary
|
|
57
|
+
# ("ErrorName: details"); raw is the full error hash.
|
|
58
|
+
def session_errored(text:, raw:)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The authoritative message.info was updated (cost, tokens, provider
|
|
62
|
+
# error metadata). Fires late in the stream after the agent closes.
|
|
63
|
+
def message_updated(info:)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Agent's internal todo list changed. Todos are whatever shape the
|
|
67
|
+
# agent's task tool uses.
|
|
68
|
+
def todos_changed(todos:)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# opencode emitted a question.asked event — the agent's `question`
|
|
72
|
+
# tool is suspended waiting for the user's reply. `request` is the
|
|
73
|
+
# full QuestionRequest hash ({id, sessionID, questions, tool?}).
|
|
74
|
+
def question_asked(request:, raw:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# opencode emitted a question.replied event — the user submitted
|
|
78
|
+
# answers (Array<Array<String>>, one inner array per question).
|
|
79
|
+
# `asked_at` is the monotonic clock value when question.asked was
|
|
80
|
+
# observed, for latency telemetry; nil if asked never arrived.
|
|
81
|
+
def question_replied(request_id:, answers:, raw:, asked_at:)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# opencode emitted a question.rejected event — the user dismissed
|
|
85
|
+
# the prompt, or it was cancelled (e.g., container shutdown).
|
|
86
|
+
def question_rejected(request_id:, raw:, asked_at:)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# opencode emitted a permission.asked event — a tool is requesting
|
|
90
|
+
# user permission to proceed. `request` is the PermissionRequest
|
|
91
|
+
# hash ({id, sessionID, permission, patterns, metadata, always, tool?}).
|
|
92
|
+
def permission_asked(request:, raw:)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# opencode emitted a permission.replied event — the user chose
|
|
96
|
+
# once/always/reject. `reply` is the string. `asked_at` per
|
|
97
|
+
# question_replied semantics.
|
|
98
|
+
def permission_replied(request_id:, reply:, raw:, asked_at:)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
module ResponseParser
|
|
5
|
+
def self.extract_text(response_body)
|
|
6
|
+
parts = response_body[:parts] || []
|
|
7
|
+
parts
|
|
8
|
+
.select { |p| p[:type] == "text" }
|
|
9
|
+
.map { |p| p[:text] }
|
|
10
|
+
.join("\n\n")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.extract_reasoning(response_body)
|
|
14
|
+
parts = response_body[:parts] || []
|
|
15
|
+
reasoning = parts
|
|
16
|
+
.select { |p| p[:type] == "reasoning" }
|
|
17
|
+
.map { |p| p[:text] }
|
|
18
|
+
.join("\n\n")
|
|
19
|
+
reasoning.presence
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
TERMINAL_STATUSES = %w[completed error].freeze
|
|
23
|
+
|
|
24
|
+
# Terminal-only tool list. Returned as canonical string-keyed hashes
|
|
25
|
+
# (same shape `extract_interleaved_parts` returns) so callers do not
|
|
26
|
+
# have to know which path produced the data.
|
|
27
|
+
def self.extract_tool_summary(response_body)
|
|
28
|
+
parts = response_body[:parts] || []
|
|
29
|
+
parts
|
|
30
|
+
.select { |p| p[:type] == "tool" && p.dig(:state, :status).in?(TERMINAL_STATUSES) }
|
|
31
|
+
.map { |p| build_tool_summary(p) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.extract_interleaved_parts(response_body)
|
|
35
|
+
parts = response_body[:parts] || []
|
|
36
|
+
|
|
37
|
+
parts.filter_map do |part|
|
|
38
|
+
case part[:type]
|
|
39
|
+
when "text"
|
|
40
|
+
{ "type" => "text", "content" => part[:text] }
|
|
41
|
+
when "reasoning"
|
|
42
|
+
{ "type" => "reasoning", "content" => part[:text] }
|
|
43
|
+
when "tool"
|
|
44
|
+
status = part.dig(:state, :status)
|
|
45
|
+
next unless status.in?(TERMINAL_STATUSES)
|
|
46
|
+
|
|
47
|
+
build_tool_summary(part)
|
|
48
|
+
else
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Canonical tool-part shape from one OpenCode message part. Delegates
|
|
55
|
+
# to Opencode::ToolPart so the streaming path (Reply#apply_tool_state)
|
|
56
|
+
# and recovery path (this method) cannot drift.
|
|
57
|
+
def self.build_tool_summary(part)
|
|
58
|
+
Opencode::ToolPart.from_message_part(part)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method :build_tool_summary
|
|
62
|
+
|
|
63
|
+
def self.extract_tokens(response_body)
|
|
64
|
+
response_body.dig(:info, :tokens)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.extract_cost(response_body)
|
|
68
|
+
response_body.dig(:info, :cost)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.extract_cache_tokens(response_body)
|
|
72
|
+
tokens = response_body.dig(:info, :tokens) || {}
|
|
73
|
+
{
|
|
74
|
+
cache_read: tokens.dig(:cache, :read) || 0,
|
|
75
|
+
cache_write: tokens.dig(:cache, :write) || 0
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.extract_error(response_body)
|
|
80
|
+
error = response_body.dig(:info, :error)
|
|
81
|
+
return nil unless error.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
name: error[:name],
|
|
85
|
+
message: error.dig(:data, :message),
|
|
86
|
+
status_code: error.dig(:data, :statusCode),
|
|
87
|
+
retryable: error.dig(:data, :isRetryable),
|
|
88
|
+
url: error.dig(:data, :metadata, :url)
|
|
89
|
+
}.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
MAX_ARTIFACT_SIZE = 10.megabytes
|
|
93
|
+
ARTIFACT_TOOLS = %w[write apply_patch].freeze
|
|
94
|
+
|
|
95
|
+
def self.extract_artifact_files(response_body)
|
|
96
|
+
parts = response_body[:parts] || []
|
|
97
|
+
completed_tools = parts.select do |p|
|
|
98
|
+
p[:type] == "tool" &&
|
|
99
|
+
ARTIFACT_TOOLS.include?(p[:tool]) &&
|
|
100
|
+
p.dig(:state, :status) == "completed"
|
|
101
|
+
end
|
|
102
|
+
return [] if completed_tools.empty?
|
|
103
|
+
|
|
104
|
+
files = completed_tools.flat_map { |part| extract_files_from_tool_part(part) }
|
|
105
|
+
files.uniq { |f| f[:filename] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.extract_artifacts_from_messages(messages)
|
|
109
|
+
return [] unless messages.is_a?(Array)
|
|
110
|
+
|
|
111
|
+
messages
|
|
112
|
+
.select { |m| m.dig(:info, :role) == "assistant" }
|
|
113
|
+
.flat_map { |m| extract_artifact_files(m) }
|
|
114
|
+
.uniq { |f| f[:filename] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.extract_files_from_tool_part(part)
|
|
118
|
+
case part[:tool]
|
|
119
|
+
when "write"
|
|
120
|
+
extract_from_write(part)
|
|
121
|
+
when "apply_patch"
|
|
122
|
+
extract_from_apply_patch(part)
|
|
123
|
+
else
|
|
124
|
+
[]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.extract_from_write(part)
|
|
129
|
+
content = part.dig(:state, :input, :content)
|
|
130
|
+
file_path = part.dig(:state, :input, :filePath)
|
|
131
|
+
return [] if content.blank? || file_path.blank?
|
|
132
|
+
return [] if content.bytesize > MAX_ARTIFACT_SIZE
|
|
133
|
+
|
|
134
|
+
filename = File.basename(file_path)
|
|
135
|
+
content_type = Marcel::MimeType.for(extension: File.extname(filename))
|
|
136
|
+
[ { filename: filename, content: content, content_type: content_type } ]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# apply_patch tool metadata shape changed materially between the early
|
|
140
|
+
# opencode versions this code originally targeted (which exposed
|
|
141
|
+
# `before` + `after` post-write file content as inline strings) and
|
|
142
|
+
# v1.4.0+ (which dropped them and only exposes the diff text in `patch`
|
|
143
|
+
# plus a `files` array of { filePath, relativePath, type, patch,
|
|
144
|
+
# additions, deletions, movePath? } descriptors). Source of truth:
|
|
145
|
+
# https://raw.githubusercontent.com/anomalyco/opencode/v1.15.0/packages/opencode/src/tool/apply_patch.ts
|
|
146
|
+
#
|
|
147
|
+
# With no `after` field in the v1.15.0 wire shape, this method previously
|
|
148
|
+
# silently returned [] for every real apply_patch invocation while still
|
|
149
|
+
# passing its (now-stale-shape) unit test — the worst kind of bug: a
|
|
150
|
+
# green test paired with a dead production path.
|
|
151
|
+
#
|
|
152
|
+
# Current behavior (intentional, until apply_patch becomes a hot path
|
|
153
|
+
# for the gem's users): we accept the v1.15.0 shape and return []. Most
|
|
154
|
+
# agents write whole files via the `write` tool rather than patching,
|
|
155
|
+
# so the practical impact today is zero. When you do use apply_patch,
|
|
156
|
+
# opencode-rails' `Opencode::Exchange#tool_artifacts` emits
|
|
157
|
+
# `opencode.apply_patch.artifacts_dropped` so operators see the silent
|
|
158
|
+
# drop and can route through the missing sandbox-read path.
|
|
159
|
+
#
|
|
160
|
+
# The event emission lives on Exchange (not here) because ResponseParser
|
|
161
|
+
# is a pure module — every other method takes a hash and returns a hash.
|
|
162
|
+
# Pure functions stay pure.
|
|
163
|
+
def self.extract_from_apply_patch(_part)
|
|
164
|
+
[]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private_class_method :extract_files_from_tool_part, :extract_from_write, :extract_from_apply_patch
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
# One todo item the OpenCode `todowrite` tool and `todo.updated` bus
|
|
5
|
+
# event carry: `content` + `status` + (optional) `priority`.
|
|
6
|
+
# Source-of-truth canonicalization lives here so Reply, ToolDisplay,
|
|
7
|
+
# and any future consumer all share one definition of "what does this
|
|
8
|
+
# todo look like once we've normalized it."
|
|
9
|
+
#
|
|
10
|
+
# Status canonicalization: OpenCode bus events have been observed
|
|
11
|
+
# emitting the hyphenated `"in-progress"` form. The rest of the
|
|
12
|
+
# codebase (per-product views, todowrite tool input shape per the
|
|
13
|
+
# v1.15+ openapi spec) uses the underscored `"in_progress"`.
|
|
14
|
+
# Canonicalize to underscore at every entry point so downstream code
|
|
15
|
+
# never has to handle both.
|
|
16
|
+
module Todo
|
|
17
|
+
HYPHENATED_TO_CANONICAL_STATUS = {
|
|
18
|
+
"in-progress" => "in_progress"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def canonical_status(status)
|
|
24
|
+
raw = status.to_s
|
|
25
|
+
HYPHENATED_TO_CANONICAL_STATUS.fetch(raw) { raw.tr("-", "_") }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Canonicalize one todo hash: string-keyed, normalized status.
|
|
29
|
+
# Returns the input unchanged when it isn't a Hash (the substrate
|
|
30
|
+
# tolerates wire-shape drift defensively).
|
|
31
|
+
def canonicalize(todo)
|
|
32
|
+
return todo unless todo.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
result = todo.deep_stringify_keys
|
|
35
|
+
result["status"] = canonical_status(result["status"]) if result.key?("status")
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def canonicalize_all(todos)
|
|
40
|
+
Array(todos).map { |t| canonicalize(t) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
# Canonical shape of a tool part in an assistant reply.
|
|
5
|
+
#
|
|
6
|
+
# A tool part starts `pending` and transitions through `running` to a
|
|
7
|
+
# terminal `completed` or `error`. The complete representation carries
|
|
8
|
+
# seven fields, all string-keyed so views read consistent keys whether
|
|
9
|
+
# the part came from a live streaming event or a post-stream message
|
|
10
|
+
# poll:
|
|
11
|
+
#
|
|
12
|
+
# "type" => "tool"
|
|
13
|
+
# "tool" => "edit"
|
|
14
|
+
# "status" => "completed"
|
|
15
|
+
# "title" => "Edited /INDEX.md"
|
|
16
|
+
# "input" => { ... } # full args the agent passed, deep-stringified
|
|
17
|
+
# "metadata" => { ... } # tool-specific output: diff, preview, stdout, etc.
|
|
18
|
+
# "output" => "Edited successfully."
|
|
19
|
+
# "error" => "..." # only when status == "error", truncated to 200 chars
|
|
20
|
+
#
|
|
21
|
+
# The shape is produced two ways:
|
|
22
|
+
#
|
|
23
|
+
# 1. Opencode::Reply#apply_tool_state — live, mid-stream, merging
|
|
24
|
+
# incoming event state into an in-memory record (previous values
|
|
25
|
+
# survive when the new event omits a field).
|
|
26
|
+
#
|
|
27
|
+
# 2. Opencode::ResponseParser.build_tool_summary — post-stream, built
|
|
28
|
+
# fresh from a complete OpenCode message returned by
|
|
29
|
+
# /session/:id/message during recovery / final-exchange polling.
|
|
30
|
+
#
|
|
31
|
+
# Existence reason: the two paths used to drift. ResponseParser stripped
|
|
32
|
+
# `metadata` and whitelisted `input` to a fixed key list, so `parts_json`
|
|
33
|
+
# saved on finalize had strictly less data than the streaming DOM had
|
|
34
|
+
# shown. The visible symptom was "I saw the diff while streaming and it
|
|
35
|
+
# disappeared when the turn finished". This class is the single source of
|
|
36
|
+
# truth that prevents that drift.
|
|
37
|
+
module ToolPart
|
|
38
|
+
MAX_ERROR_LEN = 200
|
|
39
|
+
INVALID_TOOL = "invalid"
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# Build a fresh canonical tool-part hash from one OpenCode message
|
|
44
|
+
# part (the shape that arrives through /session/:id/message).
|
|
45
|
+
# Used by ResponseParser for recovery and final-exchange polling.
|
|
46
|
+
def from_message_part(part)
|
|
47
|
+
state = state_of(part)
|
|
48
|
+
build_canonical(
|
|
49
|
+
tool: part[:tool] || part["tool"],
|
|
50
|
+
status: state_value(state, :status),
|
|
51
|
+
title: state_value(state, :title),
|
|
52
|
+
input: state_value(state, :input),
|
|
53
|
+
metadata: state_value(state, :metadata),
|
|
54
|
+
output: state_value(state, :output),
|
|
55
|
+
error: state_value(state, :error)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Merge an incoming `message.part.updated` event state into an
|
|
60
|
+
# existing record. Used by Reply#apply_tool_state during streaming.
|
|
61
|
+
#
|
|
62
|
+
# Fields the event omits (or that arrive empty) leave the record's
|
|
63
|
+
# previous value intact. Mid-tool events are partial by design.
|
|
64
|
+
#
|
|
65
|
+
# In addition to the canonical render fields (status, title, input,
|
|
66
|
+
# metadata, output, error), this also persists `callID` and
|
|
67
|
+
# `messageID` from the incoming state. Those identifiers are needed
|
|
68
|
+
# by downstream lookups (e.g. matching an ask-user reply event back
|
|
69
|
+
# to the originating tool part by callID) and would otherwise be
|
|
70
|
+
# silently dropped on the way into Reply.parts JSON.
|
|
71
|
+
#
|
|
72
|
+
# Returns the (mutated) record for chaining.
|
|
73
|
+
def merge_streaming_state(record, part)
|
|
74
|
+
state = state_of(part)
|
|
75
|
+
|
|
76
|
+
tool = part[:tool] || part["tool"]
|
|
77
|
+
# Preserve original tool name if OpenCode later renames to "invalid"
|
|
78
|
+
# mid-session — we want to keep rendering the original name.
|
|
79
|
+
record["tool"] = tool if tool.present? && tool != INVALID_TOOL
|
|
80
|
+
|
|
81
|
+
status = state_value(state, :status)
|
|
82
|
+
record["status"] = status if status
|
|
83
|
+
|
|
84
|
+
title = state_value(state, :title)
|
|
85
|
+
record["title"] = title if title.present?
|
|
86
|
+
|
|
87
|
+
input = state_value(state, :input)
|
|
88
|
+
record["input"] = stringify_deep(input) if input.present?
|
|
89
|
+
|
|
90
|
+
metadata = state_value(state, :metadata)
|
|
91
|
+
record["metadata"] = stringify_deep(metadata) if metadata.present?
|
|
92
|
+
|
|
93
|
+
output = state_value(state, :output)
|
|
94
|
+
record["output"] = output if output.present?
|
|
95
|
+
|
|
96
|
+
error = state_value(state, :error)
|
|
97
|
+
record["error"] = error.to_s.truncate(MAX_ERROR_LEN) if error.present?
|
|
98
|
+
|
|
99
|
+
# callID and messageID moved from state.* to the part's top level
|
|
100
|
+
# somewhere in opencode v1.15.x. Read top-level first, fall back
|
|
101
|
+
# to state.* for any older versions that may still be in flight.
|
|
102
|
+
# Without this, merge_pending_question_into_existing_tool_part
|
|
103
|
+
# (which searches @parts by callID) silently no-ops, and the
|
|
104
|
+
# question form renders with no questions or routing IDs.
|
|
105
|
+
call_id = part[:callID] || part["callID"] || state_value(state, :callID)
|
|
106
|
+
record["callID"] = call_id if call_id.present?
|
|
107
|
+
|
|
108
|
+
message_id = part[:messageID] || part["messageID"] || state_value(state, :messageID)
|
|
109
|
+
record["messageID"] = message_id if message_id.present?
|
|
110
|
+
|
|
111
|
+
record
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class << self
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def state_of(part)
|
|
118
|
+
part[:state] || part["state"] || {}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def state_value(state, key)
|
|
122
|
+
return nil unless state.is_a?(Hash)
|
|
123
|
+
state[key] || state[key.to_s]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_canonical(tool:, status:, title:, input:, metadata:, output:, error:)
|
|
127
|
+
hash = {
|
|
128
|
+
"type" => "tool",
|
|
129
|
+
"tool" => tool.to_s.presence,
|
|
130
|
+
"status" => status,
|
|
131
|
+
"title" => title.presence,
|
|
132
|
+
"input" => stringify_deep(input).presence,
|
|
133
|
+
"metadata" => stringify_deep(metadata).presence,
|
|
134
|
+
"output" => output.presence
|
|
135
|
+
}
|
|
136
|
+
hash["error"] = error.to_s.truncate(MAX_ERROR_LEN).presence if status == "error"
|
|
137
|
+
hash.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stringify_deep(value)
|
|
141
|
+
case value
|
|
142
|
+
when Hash
|
|
143
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_deep(v) }
|
|
144
|
+
when Array
|
|
145
|
+
value.map { |v| stringify_deep(v) }
|
|
146
|
+
else
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
# A namespacing trace emitter.
|
|
5
|
+
#
|
|
6
|
+
# Opencode::Turn emits unprefixed event names like "response.started"
|
|
7
|
+
# and "session.recreated". The host product wraps Turn in a Tracer
|
|
8
|
+
# whose job is to prepend a product prefix and forward to whatever
|
|
9
|
+
# actually emits trace events (typically the host job's
|
|
10
|
+
# `EventTraceable#trace_event`).
|
|
11
|
+
#
|
|
12
|
+
# Two responsibilities live here, and only here:
|
|
13
|
+
#
|
|
14
|
+
# 1. Callable interface: `tracer.call(name, **payload)` — the
|
|
15
|
+
# contract Turn relies on.
|
|
16
|
+
# 2. Namespacing strategy: prepend "<prefix>." to every event name.
|
|
17
|
+
#
|
|
18
|
+
# A closure-based alternative that mixes both concerns looks like:
|
|
19
|
+
#
|
|
20
|
+
# tracer: ->(name, **payload) { trace_event("myapp.#{name}", **payload) }
|
|
21
|
+
#
|
|
22
|
+
# That closure conflates the two responsibilities; every caller has
|
|
23
|
+
# to rediscover the prefix-with-period rule, and a typo only shows up
|
|
24
|
+
# in production trace data. Making it a real role removes that risk
|
|
25
|
+
# and makes the rule visible in one place.
|
|
26
|
+
#
|
|
27
|
+
# Usage:
|
|
28
|
+
#
|
|
29
|
+
# Opencode::Tracer.new(prefix: "myapp", emitter: self)
|
|
30
|
+
#
|
|
31
|
+
# `emitter` must respond to `trace_event(name, **payload)`.
|
|
32
|
+
class Tracer
|
|
33
|
+
def initialize(prefix:, emitter:)
|
|
34
|
+
@prefix = prefix
|
|
35
|
+
@emitter = emitter
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Tracer is callable so existing call sites that treated the tracer
|
|
39
|
+
# as a lambda (`tracer.call(name, **payload)`) keep working without
|
|
40
|
+
# change. Turn uses this exclusively.
|
|
41
|
+
#
|
|
42
|
+
# Uses `send` because EventTraceable's `trace_event` is a private
|
|
43
|
+
# method of the including class — the convention is "private inside
|
|
44
|
+
# the job, but the substrate's Tracer is allowed to dispatch to it
|
|
45
|
+
# the same way the job's own perform method would."
|
|
46
|
+
def call(name, **payload)
|
|
47
|
+
@emitter.send(:trace_event, "#{@prefix}.#{name}", **payload)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal ActiveSupport surface — `present?`, `blank?`, `presence`,
|
|
4
|
+
# `truncate`, `duplicable?`. We deliberately load only the core_ext bits
|
|
5
|
+
# we use, not all of activesupport, to keep the boot footprint small in
|
|
6
|
+
# non-Rails apps.
|
|
7
|
+
require "active_support/core_ext/object/blank" # provides blank?, present?, presence
|
|
8
|
+
require "active_support/core_ext/object/duplicable"
|
|
9
|
+
require "active_support/core_ext/string/filters" # provides String#truncate
|
|
10
|
+
require "active_support/core_ext/numeric/bytes" # provides Integer#megabytes
|
|
11
|
+
|
|
12
|
+
require_relative "opencode/version"
|
|
13
|
+
require_relative "opencode/error"
|
|
14
|
+
require_relative "opencode/instrumentation"
|
|
15
|
+
require_relative "opencode/response_parser"
|
|
16
|
+
require_relative "opencode/part_source"
|
|
17
|
+
require_relative "opencode/tool_part"
|
|
18
|
+
require_relative "opencode/todo"
|
|
19
|
+
require_relative "opencode/prompts"
|
|
20
|
+
require_relative "opencode/reply_observer"
|
|
21
|
+
require_relative "opencode/reply"
|
|
22
|
+
require_relative "opencode/tracer"
|
|
23
|
+
require_relative "opencode/client"
|
|
24
|
+
|
|
25
|
+
module Opencode
|
|
26
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/opencode/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "opencode-ruby"
|
|
7
|
+
spec.version = Opencode::VERSION
|
|
8
|
+
spec.authors = ["Ajay Krishnan"]
|
|
9
|
+
spec.email = ["opencode-ruby@ajay.to"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Idiomatic Ruby client for OpenCode (HTTP + SSE)."
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
Hand-rolled, opinionated Ruby SDK for OpenCode's REST + SSE API.
|
|
14
|
+
Block-form streaming, value-object responses, automatic SSE
|
|
15
|
+
reconnection. Complement to opencode_client (auto-generated from
|
|
16
|
+
OpenAPI) — pick this one if you want a small Ruby-idiomatic surface;
|
|
17
|
+
pick opencode_client if you want every endpoint with generated types.
|
|
18
|
+
DESC
|
|
19
|
+
spec.homepage = "https://github.com/ajaynomics/opencode-ruby"
|
|
20
|
+
spec.license = "MIT"
|
|
21
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
22
|
+
|
|
23
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
24
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
25
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
26
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
27
|
+
|
|
28
|
+
spec.files = Dir.glob("lib/**/*.rb") +
|
|
29
|
+
Dir.glob("examples/**/*.rb") +
|
|
30
|
+
%w[README.md LICENSE CHANGELOG.md opencode-ruby.gemspec]
|
|
31
|
+
spec.require_paths = ["lib"]
|
|
32
|
+
|
|
33
|
+
# The only runtime dependency is ActiveSupport (NOT Rails). ActiveSupport
|
|
34
|
+
# is a standalone gem providing the `present?`/`blank?`/`presence`/
|
|
35
|
+
# `truncate`/`duplicable?` helpers used in this gem's code. It does NOT
|
|
36
|
+
# pull in ActiveRecord, ActionView, ActionController, Turbo, or any other
|
|
37
|
+
# Rails-only piece. Most Ruby apps in the wild already have ActiveSupport
|
|
38
|
+
# transitively via another gem; in the rare case yours doesn't, ~250 LOC
|
|
39
|
+
# of core_ext is added when this gem installs.
|
|
40
|
+
spec.add_runtime_dependency "activesupport", ">= 6.1", "< 9.0"
|
|
41
|
+
|
|
42
|
+
spec.add_development_dependency "minitest", "~> 5.20"
|
|
43
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
44
|
+
spec.add_development_dependency "webmock", "~> 3.20"
|
|
45
|
+
end
|