mistri 0.0.2 → 0.1.0

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +177 -0
  3. data/NOTICE +9 -0
  4. data/README.md +314 -3
  5. data/lib/generators/mistri/install/install_generator.rb +54 -0
  6. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  7. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  8. data/lib/mistri/abort_signal.rb +63 -0
  9. data/lib/mistri/agent.rb +340 -0
  10. data/lib/mistri/budget.rb +29 -0
  11. data/lib/mistri/compaction.rb +78 -0
  12. data/lib/mistri/compactor.rb +182 -0
  13. data/lib/mistri/content.rb +89 -0
  14. data/lib/mistri/edit.rb +238 -0
  15. data/lib/mistri/errors.rb +94 -0
  16. data/lib/mistri/event.rb +50 -0
  17. data/lib/mistri/memory.rb +26 -0
  18. data/lib/mistri/message.rb +90 -0
  19. data/lib/mistri/models.rb +43 -0
  20. data/lib/mistri/partial_json.rb +210 -0
  21. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  22. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  23. data/lib/mistri/providers/anthropic.rb +106 -0
  24. data/lib/mistri/providers/fake.rb +109 -0
  25. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  26. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  27. data/lib/mistri/providers/gemini.rb +73 -0
  28. data/lib/mistri/providers/openai/assembler.rb +205 -0
  29. data/lib/mistri/providers/openai/serializer.rb +104 -0
  30. data/lib/mistri/providers/openai.rb +72 -0
  31. data/lib/mistri/result.rb +30 -0
  32. data/lib/mistri/retry_policy.rb +47 -0
  33. data/lib/mistri/schema.rb +162 -0
  34. data/lib/mistri/session.rb +124 -0
  35. data/lib/mistri/sinks/action_cable.rb +30 -0
  36. data/lib/mistri/sinks/coalesced.rb +61 -0
  37. data/lib/mistri/sinks/sse.rb +26 -0
  38. data/lib/mistri/skill.rb +15 -0
  39. data/lib/mistri/skills.rb +81 -0
  40. data/lib/mistri/sse.rb +50 -0
  41. data/lib/mistri/stop_reason.rb +25 -0
  42. data/lib/mistri/stores/active_record.rb +47 -0
  43. data/lib/mistri/stores/jsonl.rb +37 -0
  44. data/lib/mistri/stores/memory.rb +22 -0
  45. data/lib/mistri/sub_agent.rb +211 -0
  46. data/lib/mistri/tool.rb +94 -0
  47. data/lib/mistri/tool_call.rb +18 -0
  48. data/lib/mistri/tool_context.rb +15 -0
  49. data/lib/mistri/tool_executor.rb +66 -0
  50. data/lib/mistri/tool_result.rb +23 -0
  51. data/lib/mistri/tools/edit_file.rb +37 -0
  52. data/lib/mistri/tools/find_in_file.rb +36 -0
  53. data/lib/mistri/tools/list_files.rb +16 -0
  54. data/lib/mistri/tools/read_file.rb +38 -0
  55. data/lib/mistri/tools/read_memory.rb +16 -0
  56. data/lib/mistri/tools/update_memory.rb +22 -0
  57. data/lib/mistri/tools/write_file.rb +20 -0
  58. data/lib/mistri/tools.rb +50 -0
  59. data/lib/mistri/transport.rb +187 -0
  60. data/lib/mistri/usage.rb +79 -0
  61. data/lib/mistri/version.rb +3 -1
  62. data/lib/mistri/workspace/active_record.rb +47 -0
  63. data/lib/mistri/workspace/directory.rb +52 -0
  64. data/lib/mistri/workspace/memory.rb +40 -0
  65. data/lib/mistri/workspace/single.rb +48 -0
  66. data/lib/mistri.rb +91 -2
  67. metadata +73 -7
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Typed content blocks: what a message is made of. Text and thinking on
5
+ # assistant turns, text and images on user and tool-result turns, tool calls
6
+ # alongside them. Blocks are immutable values that compare by content,
7
+ # pattern-match, and round-trip through #to_h / Content.from_h, so sessions
8
+ # replay without the loop knowing block shapes.
9
+ module Content
10
+ # String#to_s returns self, so a caller's mutable buffer would alias into an
11
+ # immutable block; blocks own a frozen copy instead.
12
+ def self.freeze_string(value)
13
+ s = value.to_s
14
+ s.frozen? ? s : s.dup.freeze
15
+ end
16
+
17
+ # `signature` carries opaque provider metadata that must round-trip, such as
18
+ # the OpenAI Responses message id and output phase.
19
+ Text = Data.define(:text, :signature) do
20
+ def initialize(text:, signature: nil) = super(text: Content.freeze_string(text), signature:)
21
+
22
+ def type = :text
23
+
24
+ def to_h = { type: :text, text:, signature: }.compact
25
+ end
26
+
27
+ # A model's reasoning. `signature` is the opaque payload a provider needs to
28
+ # replay the block on a later turn; `redacted` marks reasoning a safety
29
+ # filter hid, leaving only the signature.
30
+ Thinking = Data.define(:thinking, :signature, :redacted) do
31
+ def initialize(thinking:, signature: nil, redacted: false)
32
+ super(thinking: Content.freeze_string(thinking), signature:, redacted:)
33
+ end
34
+
35
+ def type = :thinking
36
+
37
+ def redacted? = redacted
38
+
39
+ def to_h
40
+ h = { type: :thinking, thinking: }
41
+ h[:signature] = signature if signature
42
+ h[:redacted] = true if redacted
43
+ h
44
+ end
45
+ end
46
+
47
+ # A base64-encoded image with its MIME type.
48
+ Image = Data.define(:data, :mime_type) do
49
+ # Frozen at the pack site so the initializer's ownership check skips a
50
+ # second copy of what can be a multi-megabyte payload.
51
+ def self.from_bytes(bytes, mime_type:) = new(data: [bytes.b].pack("m0").freeze, mime_type:)
52
+
53
+ def initialize(data:, mime_type:)
54
+ super(data: Content.freeze_string(data), mime_type: Content.freeze_string(mime_type))
55
+ end
56
+
57
+ def type = :image
58
+
59
+ def bytes = data.unpack1("m0")
60
+
61
+ def to_h = { type: :image, data:, mime_type: }
62
+ end
63
+
64
+ # Coerce a value into a list of blocks: nil becomes none, a String becomes
65
+ # one Text block, blocks pass through, arrays may mix all of these.
66
+ def self.wrap(content)
67
+ Array(content).map do |block|
68
+ block.respond_to?(:type) ? block : Text.new(text: block.to_s)
69
+ end
70
+ end
71
+
72
+ # The inverse of #to_h, used when a session is read back. Keys may be
73
+ # symbols or, after a JSON round-trip, strings.
74
+ def self.from_h(hash)
75
+ h = hash.transform_keys(&:to_s)
76
+ case h["type"].to_s
77
+ when "text" then Text.new(text: h["text"], signature: h["signature"])
78
+ when "thinking"
79
+ Thinking.new(thinking: h["thinking"], signature: h["signature"],
80
+ redacted: h.fetch("redacted", false))
81
+ when "image" then Image.new(data: h["data"], mime_type: h["mime_type"])
82
+ when "tool_call"
83
+ ToolCall.new(id: h["id"], name: h["name"], arguments: h["arguments"] || {},
84
+ signature: h["signature"])
85
+ else raise ArgumentError, "unknown content block type #{h["type"].inspect}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Pure fuzzy text replacement: no files, no I/O. This is the string core that
5
+ # a workspace-backed edit tool calls, so it works the same against a database
6
+ # row as against a file on disk.
7
+ #
8
+ # Each edit's old text must match one region and only one, so an edit can
9
+ # never silently change the wrong place. Matching relaxes in two steps: an
10
+ # exact substring first, then a whitespace-tolerant line match that forgives
11
+ # the indentation and trailing-space drift models introduce when they
12
+ # reproduce code they read. Unmatched regions keep their exact bytes,
13
+ # including the file's original line endings.
14
+ module Edit
15
+ Match = Struct.new(:start, :finish, :replacement, :edit_index)
16
+ Result = Data.define(:content, :count)
17
+
18
+ module_function
19
+
20
+ # The model-facing single edit: replace old_string once (unique match
21
+ # required) or everywhere with replace_all. Returns a Result carrying the
22
+ # new content and how many places changed. The replacement adapts to the
23
+ # document's newline style, so an LF-authored new_string dropped into a
24
+ # CRLF document does not mix endings.
25
+ def replace(content, old_string, new_string, replace_all: false)
26
+ old = old_string.to_s
27
+ raise EditError, "old_string is empty" if old.empty?
28
+
29
+ new = adapt_newlines(content, new_string.to_s)
30
+ return replace_every(content, old, new) if replace_all
31
+
32
+ match = locate(content, { old: old, new: new, index: 0 })
33
+ changed = content[0...match.start] + match.replacement + content[match.finish..]
34
+ raise EditError, "the edit changed nothing" if changed == content
35
+
36
+ Result.new(content: changed, count: 1)
37
+ end
38
+
39
+ # Apply edits (each {old:, new:}, string or symbol keys) to content and
40
+ # return the new content. Raises EditError when an edit matches nothing,
41
+ # matches more than once, overlaps another, or changes nothing.
42
+ def apply(content, edits)
43
+ normalized = edits.each_with_index.map { |edit, i| normalize(edit, i) }
44
+ matches = normalized.map { |edit| locate(content, edit) }.sort_by(&:start)
45
+ reject_overlaps(matches)
46
+
47
+ result = matches.reverse.reduce(content) do |text, match|
48
+ text[0...match.start] + match.replacement + text[match.finish..]
49
+ end
50
+ raise EditError, "the edits changed nothing" if result == content
51
+
52
+ result
53
+ end
54
+
55
+ def normalize(edit, index)
56
+ edit = edit.transform_keys(&:to_sym)
57
+ old = edit[:old].to_s
58
+ raise EditError, "edits[#{index}] has empty old text" if old.empty?
59
+
60
+ { old: old, new: edit[:new].to_s, index: index }
61
+ end
62
+
63
+ # Exact match first; on a miss, a whitespace-tolerant line match. Either
64
+ # level must resolve to exactly one region. A total miss reports the
65
+ # closest region and its precise difference, so the model's retry can be
66
+ # one-shot.
67
+ def locate(content, edit)
68
+ exact_match(content, edit) || fuzzy_match(content, edit) ||
69
+ raise(EditError, not_found_message(content, edit))
70
+ end
71
+
72
+ def replace_every(content, old, new)
73
+ count = content.enum_for(:scan, old).count
74
+ raise EditError, not_found_message(content, { old: old, index: 0 }) if count.zero?
75
+
76
+ # Block form keeps both sides literal; a bare string replacement would
77
+ # interpret backslash sequences.
78
+ Result.new(content: content.gsub(old) { new }, count: count)
79
+ end
80
+
81
+ # Match the document's dominant newline style so a replacement authored
82
+ # with bare LF does not mix endings into a CRLF document.
83
+ def adapt_newlines(content, text)
84
+ crlf = content.scan("\r\n").length
85
+ bare = content.scan(/(?<!\r)\n/).length
86
+ return text.gsub(/\r?\n/, "\r\n") if crlf > bare
87
+
88
+ crlf.positive? || bare.positive? ? text.gsub("\r\n", "\n") : text
89
+ end
90
+
91
+ def exact_match(content, edit)
92
+ offsets = occurrence_offsets(content, edit[:old])
93
+ return nil if offsets.empty?
94
+
95
+ if offsets.length > 1
96
+ lines = offsets.map { |offset| line_number_at(content, offset) }
97
+ raise EditError, ambiguous_message(edit, lines)
98
+ end
99
+ first = offsets.first
100
+ Match.new(first, first + edit[:old].length, edit[:new], edit[:index])
101
+ end
102
+
103
+ def occurrence_offsets(content, needle)
104
+ offsets = []
105
+ offset = content.index(needle)
106
+ while offset
107
+ offsets << offset
108
+ offset = content.index(needle, offset + 1)
109
+ end
110
+ offsets
111
+ end
112
+
113
+ def line_number_at(content, offset) = content[0...offset].count("\n") + 1
114
+
115
+ def ambiguous_message(edit, line_numbers)
116
+ shown = line_numbers.first(4).join(", ")
117
+ shown += ", ..." if line_numbers.length > 4
118
+ "edits[#{edit[:index]}] old text matched #{line_numbers.length} places " \
119
+ "(lines #{shown}). Add surrounding lines until it is unique, or set " \
120
+ "replace_all: true to change all #{line_numbers.length}."
121
+ end
122
+
123
+ # Match the old text's lines against a window of content lines, comparing
124
+ # each line stripped of leading and trailing whitespace. The matched region
125
+ # is the exact original bytes those content lines span.
126
+ def fuzzy_match(content, edit)
127
+ lines = line_spans(content)
128
+ wanted = edit[:old].lines.map(&:strip)
129
+ wanted.pop if wanted.last == "" # a trailing newline in old text is not a line to match
130
+ return nil if wanted.empty?
131
+
132
+ windows = matching_windows(lines, wanted)
133
+ return nil if windows.empty?
134
+
135
+ raise EditError, ambiguous_message(edit, windows.map { |w| w + 1 }) if windows.length > 1
136
+
137
+ first = windows.first
138
+ Match.new(lines[first][:start], lines[first + wanted.length - 1][:finish],
139
+ edit[:new], edit[:index])
140
+ end
141
+
142
+ # When nothing matched, show the model the closest region and exactly how
143
+ # it differs, so the retry is one shot instead of a guessing loop.
144
+ def not_found_message(content, edit)
145
+ base = "edits[#{edit[:index]}] old text was not found"
146
+ near = nearest_region(content, edit[:old])
147
+ unless near
148
+ return "#{base}. Copy old_string verbatim from read_file output, " \
149
+ "without line-number prefixes."
150
+ end
151
+
152
+ "#{base}. Closest region is lines #{near[:from]}-#{near[:to]}; it differs at " \
153
+ "line #{near[:line]}: your text #{near[:yours].inspect} vs the document's " \
154
+ "#{near[:theirs].inspect}#{near[:hint]}. Copy old_string verbatim from " \
155
+ "read_file output, then resend."
156
+ end
157
+
158
+ # The window with the most stripped-equal lines, plus its first differing
159
+ # line pair.
160
+ def nearest_region(content, old_text)
161
+ lines = line_spans(content)
162
+ wanted_raw = old_text.lines.map(&:chomp)
163
+ wanted = wanted_raw.map(&:strip)
164
+ wanted.pop && wanted_raw.pop if wanted.last == ""
165
+ return nil if wanted.empty? || lines.length < wanted.length
166
+
167
+ best = best_window(lines, wanted)
168
+ return nil unless best
169
+
170
+ diff_at = (0...wanted.length).find { |j| lines[best + j][:stripped] != wanted[j] }
171
+ return nil unless diff_at
172
+
173
+ yours = wanted_raw[diff_at]
174
+ theirs = content.lines[best + diff_at].to_s.chomp
175
+ hint = yours.strip == theirs.strip ? " (differs only in whitespace)" : ""
176
+ { from: best + 1, to: best + wanted.length, line: best + diff_at + 1,
177
+ yours: yours, theirs: theirs, hint: hint }
178
+ end
179
+
180
+ # Score windows by per-line bigram similarity, so a one-character typo in
181
+ # a one-line old_string still finds its region. Only a window at least
182
+ # half-similar overall is worth reporting.
183
+ def best_window(lines, wanted)
184
+ best = nil
185
+ best_score = wanted.length / 2.0
186
+ (0..(lines.length - wanted.length)).each do |i|
187
+ score = (0...wanted.length).sum { |j| similarity(lines[i + j][:stripped], wanted[j]) }
188
+ if score > best_score
189
+ best_score = score
190
+ best = i
191
+ end
192
+ end
193
+ best
194
+ end
195
+
196
+ def similarity(left, right)
197
+ return 1.0 if left == right
198
+ return 0.0 if left.empty? || right.empty?
199
+
200
+ pairs_left = bigrams(left)
201
+ pairs_right = bigrams(right)
202
+ return 0.0 if pairs_left.empty? || pairs_right.empty?
203
+
204
+ (2.0 * (pairs_left & pairs_right).length) / (pairs_left.length + pairs_right.length)
205
+ end
206
+
207
+ def bigrams(text) = (0...(text.length - 1)).map { |i| text[i, 2] }.uniq
208
+
209
+ def matching_windows(lines, wanted)
210
+ (0..(lines.length - wanted.length)).select do |i|
211
+ wanted.each_with_index.all? { |line, j| lines[i + j][:stripped] == line }
212
+ end
213
+ end
214
+
215
+ # Each line with its character span in the original and its stripped form.
216
+ # A leading BOM is invisible to matching, and the first span starts after
217
+ # it, so a replacement at the top of the document never swallows it.
218
+ def line_spans(content)
219
+ offset = 0
220
+ content.lines.map do |line|
221
+ bom = offset.zero? && line.start_with?("\uFEFF") ? 1 : 0
222
+ span = { start: offset + bom, finish: offset + line.length,
223
+ stripped: line.delete_prefix("\uFEFF").strip }
224
+ offset += line.length
225
+ span
226
+ end
227
+ end
228
+
229
+ def reject_overlaps(matches)
230
+ matches.each_cons(2) do |a, b|
231
+ next if a.finish <= b.start
232
+
233
+ raise EditError, "edits[#{a.edit_index}] and edits[#{b.edit_index}] overlap; " \
234
+ "merge them or target separate regions"
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Root of every error Mistri raises, so a host can rescue Mistri::Error.
5
+ #
6
+ # Only failures the model cannot recover from raise: configuration, transport,
7
+ # budgets, aborts. A tool that fails during a run becomes an in-band tool
8
+ # result the model can react to, never an exception out of the loop.
9
+ class Error < StandardError; end
10
+
11
+ # Missing or contradictory setup: an unknown model, an absent API key.
12
+ class ConfigurationError < Error; end
13
+
14
+ # A provider request failed. Carries the HTTP status and response body when
15
+ # the transport got that far.
16
+ class ProviderError < Error
17
+ attr_reader :status, :body
18
+
19
+ def initialize(message = nil, status: nil, body: nil)
20
+ @status = status
21
+ @body = body
22
+ super(message || self.class.default_message)
23
+ end
24
+
25
+ # The full story for logs and error turns: the response names the fix far
26
+ # more often than the status line does.
27
+ def describe
28
+ parts = [message]
29
+ parts << "status #{status}" if status
30
+ parts << body.to_s[0, 300] if body && !body.to_s.strip.empty?
31
+ parts.join(" | ")
32
+ end
33
+
34
+ def self.default_message = "provider request failed"
35
+ end
36
+
37
+ class AuthenticationError < ProviderError
38
+ def self.default_message = "invalid or missing API key"
39
+ end
40
+
41
+ class RateLimitError < ProviderError
42
+ attr_reader :retry_after
43
+
44
+ def initialize(message = nil, retry_after: nil, **)
45
+ @retry_after = retry_after
46
+ super(message, **)
47
+ end
48
+
49
+ def self.default_message = "rate limited"
50
+ end
51
+
52
+ class OverloadedError < ProviderError
53
+ def self.default_message = "provider overloaded"
54
+ end
55
+
56
+ class ServerError < ProviderError
57
+ def self.default_message = "provider server error"
58
+ end
59
+
60
+ # Tool arguments or structured output that violate their declared schema.
61
+ class SchemaError < Error; end
62
+
63
+ # A run cancelled by the host, raised only when the caller opts into raising.
64
+ class AbortError < Error; end
65
+
66
+ # A run stopped by its turn, token, cost, or wall-clock budget.
67
+ class BudgetError < Error; end
68
+
69
+ # A text edit that did not match uniquely, or overlapped another edit.
70
+ class EditError < Error; end
71
+
72
+ # Compaction could not produce a usable summary.
73
+ class CompactionError < Error; end
74
+
75
+ # The machine-readable shape of a stream failure, carried on errored
76
+ # assistant messages so retry policies and hosts can classify without
77
+ # parsing prose. Strings are the assemblers' synthesized truncation
78
+ # reasons.
79
+ module ErrorData
80
+ module_function
81
+
82
+ def for(reason)
83
+ case reason
84
+ when RateLimitError
85
+ { "type" => "RateLimitError", "status" => reason.status,
86
+ "retry_after" => reason.retry_after }.compact
87
+ when ProviderError
88
+ { "type" => reason.class.name.split("::").last, "status" => reason.status }.compact
89
+ when Exception then { "type" => reason.class.name }
90
+ else { "type" => "TruncatedStream" }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # One event in a streamed assistant turn. A stream is one :start, then a
5
+ # start/delta/end trio per content block (text, thinking, or toolcall), then
6
+ # exactly one terminal event: :done on success or :error on failure, carrying
7
+ # the complete message and its stop reason.
8
+ #
9
+ # `partial` is an immutable snapshot of the assistant message so far, safe to
10
+ # hold across events. `content_index` is the block's position in that
11
+ # message's content list.
12
+ # origin names the sub-agent an event came from: nil for this agent's own
13
+ # turns, and nesting joins names left to right ("researcher>writer").
14
+ class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
15
+ :reason, :message, :error_message, :partial, :origin)
16
+ # The stream types come from a provider mid-turn; the loop adds
17
+ # :tool_result after it runs each tool, :approval_needed when a gated
18
+ # call parks for a human, and :compacting/:compaction around a context
19
+ # compaction, so one subscription sees the whole exchange.
20
+ TYPES = %i[
21
+ start
22
+ text_start text_delta text_end
23
+ thinking_start thinking_delta thinking_end
24
+ toolcall_start toolcall_delta toolcall_end
25
+ done error
26
+ tool_result approval_needed
27
+ compacting compaction
28
+ retry
29
+ ].freeze
30
+
31
+ def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
32
+ reason: nil, message: nil, error_message: nil, partial: nil, origin: nil)
33
+ raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
34
+
35
+ super
36
+ end
37
+
38
+ def done? = type == :done
39
+
40
+ def error? = type == :error
41
+
42
+ def terminal? = done? || error?
43
+
44
+ # Partials are ephemeral streaming state and stay out of serialization.
45
+ def to_h
46
+ { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
47
+ reason:, message: message&.to_h, error_message:, origin: }.compact
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Durable knowledge that outlives a session, living wherever the host says:
5
+ # an org's row, a user record, a file. What memory means (per org, per
6
+ # user, per project) is the host's call; Mistri only reads and replaces it.
7
+ #
8
+ # memory = Mistri::Memory.new(
9
+ # read: -> { org.agent_memory.to_s },
10
+ # write: ->(text) { org.update!(agent_memory: text) }
11
+ # )
12
+ # agent = Mistri.agent("claude-opus-4-8", tools: [*Mistri::Tools.memory(memory)])
13
+ class Memory
14
+ def initialize(read:, write:)
15
+ @read = read
16
+ @write = write
17
+ end
18
+
19
+ def read = @read.call.to_s
20
+
21
+ def replace(content)
22
+ @write.call(content.to_s)
23
+ nil
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # One message in a conversation, the single shape every provider translates to
5
+ # and from its wire format. Immutable: streaming builds snapshots, sessions
6
+ # replay values, and nothing aliases across threads.
7
+ #
8
+ # Roles: :system, :user, :assistant, :tool (a tool result linked back by
9
+ # tool_call_id). Assistant messages carry the model and provider that produced
10
+ # them, which is what lets a later turn replay history across models, plus
11
+ # usage and the stop reason.
12
+ #
13
+ # ui is a tool result's host-only channel: it persists with the message and
14
+ # rides its :tool_result event, but no serializer ever sends it to a model.
15
+ # error is the machine-readable failure on an errored turn (ErrorData
16
+ # shape); error_message stays the human story.
17
+ class Message < Data.define(:role, :content, :tool_call_id, :tool_name,
18
+ :model, :provider, :usage, :stop_reason, :error_message, :ui,
19
+ :error)
20
+ ROLES = %i[system user assistant tool].freeze
21
+
22
+ def initialize(role:, content: nil, tool_call_id: nil, tool_name: nil, model: nil,
23
+ provider: nil, usage: nil, stop_reason: nil, error_message: nil, ui: nil,
24
+ error: nil)
25
+ role = role.to_sym
26
+ raise ArgumentError, "unknown role #{role.inspect}" unless ROLES.include?(role)
27
+ if stop_reason && !StopReason.valid?(stop_reason)
28
+ raise ArgumentError, "unknown stop reason #{stop_reason.inspect}"
29
+ end
30
+
31
+ super(role:, content: Content.wrap(content).freeze, tool_call_id:, tool_name:,
32
+ model:, provider:, usage:, stop_reason:, error_message:, ui:, error:)
33
+ end
34
+
35
+ def self.system(content) = new(role: :system, content:)
36
+
37
+ def self.user(content) = new(role: :user, content:)
38
+
39
+ # A user turn carrying images alongside optional text.
40
+ def self.user_with_images(content, images = [])
41
+ images = Array(images)
42
+ return user(content) if images.empty?
43
+
44
+ text = content.to_s
45
+ blocks = text.empty? ? images : [Content::Text.new(text:), *images]
46
+ new(role: :user, content: blocks)
47
+ end
48
+
49
+ def self.assistant(content: nil, tool_calls: [], **meta)
50
+ new(role: :assistant, content: [*Content.wrap(content), *tool_calls], **meta)
51
+ end
52
+
53
+ def self.tool(content:, tool_call_id:, tool_name: nil, ui: nil)
54
+ new(role: :tool, content:, tool_call_id:, tool_name:, ui:)
55
+ end
56
+
57
+ def self.from_h(hash)
58
+ h = hash.transform_keys(&:to_s)
59
+ new(role: h.fetch("role").to_sym,
60
+ content: Array(h["content"]).map { |block| Content.from_h(block) },
61
+ tool_call_id: h["tool_call_id"], tool_name: h["tool_name"],
62
+ model: h["model"], provider: h["provider"]&.to_sym,
63
+ usage: h["usage"] && Usage.from_h(h["usage"]),
64
+ stop_reason: h["stop_reason"]&.to_sym, error_message: h["error_message"],
65
+ ui: h["ui"], error: h["error"])
66
+ end
67
+
68
+ def system? = role == :system
69
+ def user? = role == :user
70
+ def assistant? = role == :assistant
71
+ def tool? = role == :tool
72
+
73
+ # Every Text block joined, or nil when the turn carried no text.
74
+ def text
75
+ texts = content.grep(Content::Text)
76
+ texts.empty? ? nil : texts.map(&:text).join
77
+ end
78
+
79
+ def tool_calls = content.grep(ToolCall)
80
+
81
+ def tool_calls? = content.any?(ToolCall)
82
+
83
+ # A serialization shape, not the member hash: rebuild with .from_h,
84
+ # never with new(**to_h).
85
+ def to_h
86
+ { role:, content: content.map(&:to_h), tool_call_id:, tool_name:, model:,
87
+ provider:, usage: usage&.to_h, stop_reason:, error_message:, ui:, error: }.compact
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # The model catalog: capability data for known models, with graceful
5
+ # passthrough for unknown ones. An id missing here still works everywhere;
6
+ # the catalog only improves defaults (output ceilings, provider inference),
7
+ # so a brand-new model is usable the day it ships.
8
+ module Models
9
+ # thinking is how the model accepts a reasoning request: :adaptive (the
10
+ # model decides), :budget (a token budget), or :effort. It is what keeps
11
+ # a provider from sending an unsupported thinking shape that 400s.
12
+ Model = Data.define(:id, :provider, :max_output, :context_window, :thinking)
13
+
14
+ CATALOG = [
15
+ ["claude-fable-5", :anthropic, 128_000, 200_000, :adaptive],
16
+ ["claude-opus-4-8", :anthropic, 128_000, 200_000, :adaptive],
17
+ ["claude-opus-4-7", :anthropic, 128_000, 200_000, :adaptive],
18
+ ["claude-opus-4-6", :anthropic, 128_000, 200_000, :adaptive],
19
+ ["claude-sonnet-5", :anthropic, 128_000, 200_000, :adaptive],
20
+ ["claude-sonnet-4-6", :anthropic, 128_000, 200_000, :adaptive],
21
+ ["claude-haiku-4-5", :anthropic, 64_000, 200_000, :budget],
22
+ ["gpt-5.5", :openai, 128_000, 400_000, :effort],
23
+ ["gpt-5.4", :openai, 128_000, 400_000, :effort],
24
+ ["gpt-5-nano", :openai, 128_000, 400_000, :effort],
25
+ ["gemini-3.5-flash", :gemini, 65_536, 1_048_576, :level],
26
+ ["gemini-3.1-pro-preview", :gemini, 65_536, 1_048_576, :level],
27
+ ["gemini-2.5-pro", :gemini, 65_536, 1_048_576, :budget],
28
+ ["gemini-2.5-flash", :gemini, 65_536, 1_048_576, :budget]
29
+ ].to_h do |id, provider, max_output, context_window, thinking|
30
+ [id, Model.new(id:, provider:, max_output:, context_window:, thinking:)]
31
+ end.freeze
32
+
33
+ # Dated aliases resolve to their base entry: claude-opus-4-8-20260115 and
34
+ # gpt-5.4-2025-04-14 both match their base ids.
35
+ def self.find(id)
36
+ CATALOG[id] || CATALOG[id.to_s.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")]
37
+ end
38
+
39
+ def self.max_output(id) = find(id)&.max_output
40
+
41
+ def self.thinking(id) = find(id)&.thinking
42
+ end
43
+ end