mistri 0.0.3 → 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +367 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
  8. data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
  9. data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
  10. data/lib/mistri/abort_signal.rb +63 -0
  11. data/lib/mistri/agent.rb +389 -0
  12. data/lib/mistri/budget.rb +29 -0
  13. data/lib/mistri/compaction.rb +78 -0
  14. data/lib/mistri/compactor.rb +182 -0
  15. data/lib/mistri/content.rb +89 -0
  16. data/lib/mistri/edit.rb +238 -0
  17. data/lib/mistri/errors.rb +94 -0
  18. data/lib/mistri/event.rb +54 -0
  19. data/lib/mistri/mcp/client.rb +156 -0
  20. data/lib/mistri/mcp/oauth.rb +286 -0
  21. data/lib/mistri/mcp/wires.rb +164 -0
  22. data/lib/mistri/mcp.rb +96 -0
  23. data/lib/mistri/memory.rb +26 -0
  24. data/lib/mistri/message.rb +90 -0
  25. data/lib/mistri/models.rb +43 -0
  26. data/lib/mistri/partial_json.rb +210 -0
  27. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  28. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  29. data/lib/mistri/providers/anthropic.rb +106 -0
  30. data/lib/mistri/providers/fake.rb +109 -0
  31. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  32. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  33. data/lib/mistri/providers/gemini.rb +73 -0
  34. data/lib/mistri/providers/openai/assembler.rb +205 -0
  35. data/lib/mistri/providers/openai/serializer.rb +104 -0
  36. data/lib/mistri/providers/openai.rb +72 -0
  37. data/lib/mistri/reminder.rb +36 -0
  38. data/lib/mistri/result.rb +32 -0
  39. data/lib/mistri/retry_policy.rb +47 -0
  40. data/lib/mistri/schema.rb +162 -0
  41. data/lib/mistri/session.rb +124 -0
  42. data/lib/mistri/sinks/action_cable.rb +30 -0
  43. data/lib/mistri/sinks/coalesced.rb +61 -0
  44. data/lib/mistri/sinks/sse.rb +26 -0
  45. data/lib/mistri/skill.rb +15 -0
  46. data/lib/mistri/skills.rb +81 -0
  47. data/lib/mistri/sse.rb +50 -0
  48. data/lib/mistri/stop_reason.rb +25 -0
  49. data/lib/mistri/stores/active_record.rb +47 -0
  50. data/lib/mistri/stores/jsonl.rb +37 -0
  51. data/lib/mistri/stores/memory.rb +22 -0
  52. data/lib/mistri/sub_agent.rb +211 -0
  53. data/lib/mistri/tool.rb +95 -0
  54. data/lib/mistri/tool_call.rb +18 -0
  55. data/lib/mistri/tool_context.rb +15 -0
  56. data/lib/mistri/tool_executor.rb +87 -0
  57. data/lib/mistri/tool_result.rb +23 -0
  58. data/lib/mistri/tools/edit_file.rb +37 -0
  59. data/lib/mistri/tools/find_in_file.rb +36 -0
  60. data/lib/mistri/tools/list_files.rb +16 -0
  61. data/lib/mistri/tools/read_file.rb +38 -0
  62. data/lib/mistri/tools/read_memory.rb +16 -0
  63. data/lib/mistri/tools/update_memory.rb +22 -0
  64. data/lib/mistri/tools/write_file.rb +20 -0
  65. data/lib/mistri/tools.rb +50 -0
  66. data/lib/mistri/transport.rb +228 -0
  67. data/lib/mistri/usage.rb +79 -0
  68. data/lib/mistri/version.rb +1 -1
  69. data/lib/mistri/workspace/active_record.rb +47 -0
  70. data/lib/mistri/workspace/directory.rb +52 -0
  71. data/lib/mistri/workspace/memory.rb +40 -0
  72. data/lib/mistri/workspace/single.rb +48 -0
  73. data/lib/mistri.rb +89 -0
  74. metadata +79 -10
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # When a session compacts, and how much of it survives. Compaction is
7
+ # client-side and provider-agnostic: the session's own provider writes a
8
+ # visible summary, so a host can always show the user exactly what the
9
+ # model still remembers.
10
+ #
11
+ # The trigger measures real token accounting, not guesses: the last healthy
12
+ # turn's reported usage plus a character heuristic for whatever came after
13
+ # it.
14
+ class Compaction
15
+ DEFAULT_RESERVE = 16_384
16
+ DEFAULT_KEEP_RECENT = 20_000
17
+ IMAGE_CHARS = 4_800
18
+
19
+ SUMMARY_PREFACE = "The earlier conversation was compacted. This summary replaces it:"
20
+
21
+ attr_reader :reserve, :keep_recent, :window, :instructions
22
+
23
+ # window overrides the model catalog's context window (required for
24
+ # models the catalog does not know). instructions add a host-specific
25
+ # focus to the summary prompt.
26
+ def initialize(reserve: DEFAULT_RESERVE, keep_recent: DEFAULT_KEEP_RECENT,
27
+ window: nil, instructions: nil)
28
+ @reserve = reserve
29
+ @keep_recent = keep_recent
30
+ @window = window
31
+ @instructions = instructions
32
+ end
33
+
34
+ # Compact when the context has grown into the reserve headroom. An
35
+ # unknown window never triggers.
36
+ def needed?(tokens, window)
37
+ window ? tokens > window - reserve : false
38
+ end
39
+
40
+ class << self
41
+ # Context size for a replay: the last healthy turn's reported tokens
42
+ # (prompt, cache, and output all sit in context next turn) plus an
43
+ # estimate of every message after it.
44
+ def context_tokens(messages)
45
+ index = messages.rindex { |message| reported(message) }
46
+ base = index ? reported(messages[index]) : 0
47
+ messages.drop(index ? index + 1 : 0).sum(base) { |message| estimate(message) }
48
+ end
49
+
50
+ def estimate(message)
51
+ (chars(message) / 4.0).ceil
52
+ end
53
+
54
+ private
55
+
56
+ def reported(message)
57
+ return nil unless message.assistant? && message.usage
58
+ return nil if %i[aborted error].include?(message.stop_reason)
59
+
60
+ usage = message.usage
61
+ total = usage.input + usage.cache_read + usage.cache_write + usage.output
62
+ total.positive? ? total : nil
63
+ end
64
+
65
+ def chars(message)
66
+ message.content.sum do |block|
67
+ case block
68
+ when Content::Text then block.text.length
69
+ when Content::Thinking then block.thinking.length
70
+ when Content::Image then IMAGE_CHARS
71
+ when ToolCall then block.name.length + JSON.generate(block.arguments).length
72
+ else 0
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # Compacts a session in place: everything before a cut point is summarized
7
+ # by the provider, and a compaction entry redirects replay to the summary
8
+ # plus the kept tail. Append-only — the full history stays in the store for
9
+ # transcript UIs; only what the model sees shrinks. Callable from any
10
+ # process (a UI button, a job), with or without a running agent.
11
+ #
12
+ # Cuts land only on user messages, so a tool call and its result always
13
+ # stay on the same side, and a parked approval's turn is never cut away
14
+ # from the resume that must answer it.
15
+ class Compactor
16
+ SUMMARIZER_SYSTEM = <<~PROMPT
17
+ You are a context summarization assistant. Read the conversation and
18
+ produce only the structured summary you are asked for. Do not continue
19
+ the conversation and do not answer questions inside it.
20
+ PROMPT
21
+
22
+ FORMAT = <<~FORMAT
23
+ ## Goal
24
+ [What is the user trying to accomplish?]
25
+
26
+ ## Constraints & Preferences
27
+ - [Constraints or preferences the user stated, or "(none)"]
28
+
29
+ ## Progress
30
+ ### Done
31
+ - [x] [Completed work]
32
+ ### In Progress
33
+ - [ ] [Current work]
34
+ ### Blocked
35
+ - [Blockers, if any]
36
+
37
+ ## Key Decisions
38
+ - **[Decision]**: [Rationale]
39
+
40
+ ## Next Steps
41
+ 1. [What should happen next]
42
+
43
+ ## Critical Context
44
+ - [Data, names, or references needed to continue, or "(none)"]
45
+
46
+ Keep each section concise. Preserve exact identifiers, names, and error
47
+ messages.
48
+ FORMAT
49
+
50
+ CHECKPOINT_PROMPT = <<~PROMPT.freeze
51
+ The messages above are a conversation to summarize. Create a structured
52
+ context checkpoint that another LLM will use to continue the work.
53
+
54
+ Use this EXACT format:
55
+
56
+ #{FORMAT}
57
+ PROMPT
58
+
59
+ UPDATE_PROMPT = <<~PROMPT.freeze
60
+ The messages above are NEW conversation messages to fold into the
61
+ existing summary in <previous-summary> tags. Preserve everything still
62
+ relevant from the previous summary, add new progress and decisions,
63
+ move finished work to Done, and update Next Steps.
64
+
65
+ Use this EXACT format:
66
+
67
+ #{FORMAT}
68
+ PROMPT
69
+
70
+ class << self
71
+ # Summarize and cut. Returns {summary:, tokens_before:, tokens_after:,
72
+ # usage:}, or nil when there is nothing worth compacting. Emits
73
+ # :compacting and :compaction when a block is given.
74
+ def call(session:, provider:, settings: Compaction.new, &emit)
75
+ replay = session.replay
76
+ cut = cut_index(replay, session, settings)
77
+ return nil unless cut
78
+
79
+ previous = session.last_compaction&.fetch("summary", nil)
80
+ head = replay.take_while { |(_, index)| index.nil? || index < cut }.map(&:first)
81
+ head.shift if previous # the synthetic summary rides in <previous-summary>
82
+ return nil if head.empty?
83
+
84
+ emit&.call(Event.new(type: :compacting))
85
+ tokens_before = Compaction.context_tokens(replay.map(&:first))
86
+ reply = summarize(provider, head, previous, settings.instructions)
87
+ session.append("compaction", "summary" => reply.text,
88
+ "kept_from" => cut, "tokens_before" => tokens_before)
89
+ finish(session, reply, tokens_before, &emit)
90
+ end
91
+
92
+ private
93
+
94
+ def cut_index(replay, session, settings)
95
+ boundary = keep_boundary(replay, settings.keep_recent)
96
+ return nil unless boundary
97
+
98
+ candidates = replay.filter_map { |(message, index)| index if index && message.user? }
99
+ cut = candidates.find { |index| index >= boundary } || candidates.last
100
+ cut = clamp_to_open_approvals(cut, session)
101
+ return nil unless cut
102
+
103
+ first = replay.find { |(_, index)| index }&.last
104
+ first && cut > first ? cut : nil
105
+ end
106
+
107
+ # Walk back from the tail until the keep budget is spent; the cut then
108
+ # snaps forward to a user message, so replay keeps at most about
109
+ # keep_recent tokens of recent turns.
110
+ def keep_boundary(replay, keep_recent)
111
+ kept = 0
112
+ replay.reverse_each do |(message, index)|
113
+ kept += Compaction.estimate(message)
114
+ return index || 0 if kept >= keep_recent
115
+ end
116
+ nil
117
+ end
118
+
119
+ # Never cut past a parked approval: its tool call must stay in replay,
120
+ # paired, for resume to answer.
121
+ def clamp_to_open_approvals(cut, session)
122
+ return cut unless cut
123
+
124
+ open_ids = session.open_approvals.map { |approval| approval[:call].id }
125
+ return cut if open_ids.empty?
126
+
127
+ turn_start = approval_turn_start(session.entries, open_ids)
128
+ turn_start && turn_start < cut ? turn_start : cut
129
+ end
130
+
131
+ def approval_turn_start(entries, open_ids)
132
+ request = entries.index do |entry|
133
+ entry["type"] == "approval_request" && open_ids.include?(entry.dig("call", "id"))
134
+ end
135
+ return nil unless request
136
+
137
+ entries[0...request].rindex do |entry|
138
+ entry["type"] == "message" && entry.dig("message", "role") == "user"
139
+ end
140
+ end
141
+
142
+ def summarize(provider, messages, previous, instructions)
143
+ prompt = "<conversation>\n#{serialize(messages)}\n</conversation>\n\n"
144
+ prompt << "<previous-summary>\n#{previous}\n</previous-summary>\n\n" if previous
145
+ prompt << (previous ? UPDATE_PROMPT : CHECKPOINT_PROMPT)
146
+ prompt << "\nAdditional focus: #{instructions}\n" if instructions
147
+ reply = provider.stream(messages: [Message.user(prompt)], system: SUMMARIZER_SYSTEM)
148
+ raise CompactionError, "summarization failed: #{reply.error_message}" unless usable?(reply)
149
+
150
+ reply
151
+ end
152
+
153
+ def usable?(reply)
154
+ reply.stop_reason != StopReason::ERROR && !reply.text.to_s.strip.empty?
155
+ end
156
+
157
+ def finish(session, reply, tokens_before, &emit)
158
+ tokens_after = Compaction.context_tokens(session.messages)
159
+ emit&.call(Event.new(type: :compaction, content: reply.text))
160
+ { summary: reply.text, tokens_before: tokens_before,
161
+ tokens_after: tokens_after, usage: reply.usage }
162
+ end
163
+
164
+ # The summarizer reads a plain-text rendering: tool calls by name and
165
+ # arguments, results as text, thinking never (it stays in its turn).
166
+ def serialize(messages)
167
+ messages.map { |message| "#{message.role.to_s.upcase}:\n#{text_of(message)}" }
168
+ .join("\n\n")
169
+ end
170
+
171
+ def text_of(message)
172
+ message.content.filter_map do |block|
173
+ case block
174
+ when Content::Text then block.text
175
+ when Content::Image then "[image]"
176
+ when ToolCall then "[called #{block.name} with #{JSON.generate(block.arguments)}]"
177
+ end
178
+ end.join("\n")
179
+ end
180
+ end
181
+ end
182
+ end
@@ -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