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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Built-in tools. Tools.files binds the document tools to a workspace, so
5
+ # "file" means whatever the workspace says it means: a database column, a
6
+ # row in a documents table, or an actual file. The names stay read_file and
7
+ # edit_file because those are the tool names models are trained on.
8
+ module Tools
9
+ ALIASES = { "oldText" => "old_string", "old" => "old_string", "search" => "old_string",
10
+ "newText" => "new_string", "new" => "new_string", "replace" => "new_string",
11
+ "replaceAll" => "replace_all", "file" => "path", "filename" => "path" }.freeze
12
+
13
+ module_function
14
+
15
+ def files(workspace)
16
+ [read_file(workspace), write_file(workspace), edit_file(workspace),
17
+ find_in_file(workspace), list_files(workspace)]
18
+ end
19
+
20
+ def memory(store)
21
+ [read_memory(store), update_memory(store)]
22
+ end
23
+
24
+ def with_document(workspace, args)
25
+ content = workspace.read(args["path"])
26
+ return "No document at #{args["path"].inspect}. Use list_files to see paths." if content.nil?
27
+
28
+ yield content
29
+ end
30
+
31
+ # Absorb the drift real models produce: alias keys, stringly booleans,
32
+ # unknown keys dropped by simply never being read.
33
+ def tolerate(args)
34
+ normalized = args.to_h { |key, value| [ALIASES.fetch(key.to_s, key.to_s), value] }
35
+ case normalized["replace_all"]
36
+ when "true", "1", 1 then normalized["replace_all"] = true
37
+ when "false", "0", 0, nil then normalized["replace_all"] = false
38
+ end
39
+ normalized
40
+ end
41
+ end
42
+ end
43
+
44
+ require_relative "tools/read_file"
45
+ require_relative "tools/write_file"
46
+ require_relative "tools/edit_file"
47
+ require_relative "tools/find_in_file"
48
+ require_relative "tools/list_files"
49
+ require_relative "tools/read_memory"
50
+ require_relative "tools/update_memory"
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Mistri
8
+ # HTTP for one provider origin, held open across the turns of a run: a
9
+ # multi-turn agent pays the TCP and TLS handshake once, not per turn. Not
10
+ # shareable across threads; a mutex serializes accidental concurrent use onto
11
+ # the single socket, and re-entering from a streaming callback raises
12
+ # ThreadError.
13
+ #
14
+ # Streaming reads abort two ways: cooperatively between fragments, and hard,
15
+ # by closing the socket from the abort signal's callback, so a stalled read
16
+ # stops immediately instead of waiting out the read timeout.
17
+ class Transport
18
+ KEEP_ALIVE_SECONDS = 30
19
+
20
+ def initialize(origin:, open_timeout: 15, read_timeout: 300, write_timeout: 60)
21
+ @origin = origin.to_s.chomp("/")
22
+ @uri = URI(@origin)
23
+ @open_timeout = open_timeout
24
+ @read_timeout = read_timeout
25
+ @write_timeout = write_timeout
26
+ @mutex = Mutex.new
27
+ @connection = nil
28
+ end
29
+
30
+ # POST and decode a JSON response body. Retries once on a dead idle
31
+ # socket, so it suits idempotent endpoints.
32
+ def post(path, body:, headers: {})
33
+ response = @mutex.synchronize do
34
+ with_retry { connection.request(build_request(path, body, headers)) }
35
+ end
36
+ raise_for_status(response)
37
+ JSON.parse(response.body)
38
+ end
39
+
40
+ # POST and stream the SSE response, yielding each decoded data record.
41
+ # Returns :aborted when the signal cancelled the stream, else nil.
42
+ def stream_post(path, body:, headers: {}, signal: nil, &block)
43
+ return :aborted if signal&.aborted?
44
+
45
+ @mutex.synchronize { stream_locked(path, body, headers, signal, &block) }
46
+ end
47
+
48
+ # POST for Streamable-HTTP endpoints (the MCP shape) that answer either
49
+ # a JSON body or an SSE stream: yields each JSON record either way and
50
+ # returns the response headers, downcased. Retries only a dead idle
51
+ # socket that failed before any response started, so a side-effecting
52
+ # call can never run twice.
53
+ def post_either(path, body:, headers: {}, &block)
54
+ @mutex.synchronize do
55
+ retried = false
56
+ begin
57
+ started = false
58
+ response_headers = nil
59
+ connection.request(build_request(path, body, headers, streaming: true)) do |response|
60
+ started = true
61
+ raise_for_status(response)
62
+ response_headers = response.to_hash.transform_values(&:first)
63
+ read_either(response, &block)
64
+ end
65
+ response_headers
66
+ rescue IOError, SocketError, SystemCallError, Timeout::Error => e
67
+ teardown
68
+ if started || retried || e.is_a?(Timeout::Error)
69
+ raise ProviderError, "connection failed: #{e.message}"
70
+ end
71
+
72
+ retried = true
73
+ retry
74
+ end
75
+ end
76
+ end
77
+
78
+ def close
79
+ @mutex.synchronize { teardown }
80
+ end
81
+
82
+ private
83
+
84
+ def read_either(response, &block)
85
+ if response["content-type"].to_s.include?("text/event-stream")
86
+ sse = SSE.new
87
+ response.read_body { |chunk| sse.feed(chunk, &block) }
88
+ sse.finish(&block)
89
+ else
90
+ raw = response.read_body
91
+ block.call(JSON.parse(raw)) unless raw.to_s.strip.empty?
92
+ end
93
+ end
94
+
95
+ def stream_locked(path, body, headers, signal, &block)
96
+ retried = false
97
+ begin
98
+ started = false
99
+ aborted = false
100
+ conn = connection
101
+ # The closer targets this turn's connection, so an abort that fires
102
+ # late, after the turn already finished, cannot touch a successor's
103
+ # socket.
104
+ closer = signal&.on_abort { quietly_finish(conn) }
105
+ begin
106
+ conn.request(build_request(path, body, headers, streaming: true)) do |response|
107
+ started = true
108
+ raise_for_status(response)
109
+ aborted = read_stream(response, signal, &block)
110
+ end
111
+ ensure
112
+ signal&.remove_callback(closer) if closer
113
+ end
114
+ # An abort mid-body leaves unread bytes on the socket; drop it rather
115
+ # than let the next request read stale frames.
116
+ teardown if aborted
117
+ aborted ? :aborted : nil
118
+ rescue IOError, SocketError, SystemCallError, Timeout::Error => e
119
+ teardown
120
+ return :aborted if signal&.aborted?
121
+
122
+ # A dead idle keep-alive socket fails before the response starts and
123
+ # retries safely. A drop after events flowed must not replay the turn.
124
+ if !started && !retried
125
+ retried = true
126
+ retry
127
+ end
128
+ raise ProviderError, "connection lost mid-stream: #{e.message}"
129
+ end
130
+ end
131
+
132
+ def read_stream(response, signal, &block)
133
+ sse = SSE.new
134
+ aborted = false
135
+ response.read_body do |fragment|
136
+ if signal&.aborted?
137
+ aborted = true
138
+ break
139
+ end
140
+ sse.feed(fragment, &block)
141
+ end
142
+ sse.finish(&block) unless aborted
143
+ aborted
144
+ end
145
+
146
+ def build_request(path, body, headers, streaming: false)
147
+ request = Net::HTTP::Post.new(URI("#{@origin}#{path}"))
148
+ request["Content-Type"] = "application/json"
149
+ if streaming
150
+ request["Accept"] = "text/event-stream"
151
+ # Net::HTTP silently negotiates gzip, and its inflater buffers the
152
+ # whole stream, delivering "live" events in one burst at the end.
153
+ request["Accept-Encoding"] = "identity"
154
+ end
155
+ headers.each { |key, value| request[key] = value }
156
+ request.body = JSON.generate(body)
157
+ request
158
+ end
159
+
160
+ def with_retry
161
+ attempted = false
162
+ begin
163
+ yield
164
+ rescue Timeout::Error => e
165
+ # The server may already be executing the request; a replay risks
166
+ # running it twice.
167
+ teardown
168
+ raise ProviderError, "request timed out: #{e.message}"
169
+ rescue IOError, SocketError, SystemCallError => e
170
+ teardown
171
+ raise ProviderError, "connection failed: #{e.message}" if attempted
172
+
173
+ attempted = true
174
+ retry
175
+ end
176
+ end
177
+
178
+ def connection
179
+ @connection ||= Net::HTTP.new(@uri.host, @uri.port).tap do |http|
180
+ http.use_ssl = @uri.scheme == "https"
181
+ http.open_timeout = @open_timeout
182
+ http.read_timeout = @read_timeout
183
+ http.write_timeout = @write_timeout
184
+ http.keep_alive_timeout = KEEP_ALIVE_SECONDS
185
+ http.start
186
+ end
187
+ end
188
+
189
+ def teardown
190
+ @connection&.finish
191
+ rescue IOError
192
+ nil
193
+ ensure
194
+ @connection = nil
195
+ end
196
+
197
+ def quietly_finish(conn)
198
+ conn.finish
199
+ rescue IOError
200
+ nil
201
+ end
202
+
203
+ def raise_for_status(response)
204
+ status = response.code.to_i
205
+ return if (200..299).cover?(status)
206
+
207
+ klass = error_class(status)
208
+ options = { status: status, body: response.read_body.to_s[0, 500] }
209
+ options[:retry_after] = retry_after(response) if klass == RateLimitError
210
+ raise klass.new(**options)
211
+ end
212
+
213
+ def error_class(status)
214
+ case status
215
+ when 401, 403 then AuthenticationError
216
+ when 429 then RateLimitError
217
+ when 529 then OverloadedError
218
+ when 500..599 then ServerError
219
+ else ProviderError
220
+ end
221
+ end
222
+
223
+ def retry_after(response)
224
+ value = response["retry-after"]
225
+ value&.match?(/\A\d+(\.\d+)?\z/) ? value.to_f : nil
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Token accounting for an assistant turn, and the dollar cost of those tokens.
5
+ #
6
+ # `input` counts only prompt tokens billed at the full rate: cache reads and
7
+ # writes are separate fields, so a cached token is never double-billed.
8
+ # `reasoning` is the thinking slice of `output`. `cache_write_1h` is the slice
9
+ # of `cache_write` held for an hour, which bills at twice the input rate.
10
+ class Usage < Data.define(:input, :output, :cache_read, :cache_write,
11
+ :cache_write_1h, :reasoning, :cost)
12
+ Cost = Data.define(:input, :output, :cache_read, :cache_write, :total) do
13
+ def self.zero = new(input: 0.0, output: 0.0, cache_read: 0.0, cache_write: 0.0, total: 0.0)
14
+
15
+ def +(other)
16
+ self.class.new(input: input + other.input, output: output + other.output,
17
+ cache_read: cache_read + other.cache_read,
18
+ cache_write: cache_write + other.cache_write,
19
+ total: total + other.total)
20
+ end
21
+ end
22
+
23
+ def initialize(input: 0, output: 0, cache_read: 0, cache_write: 0,
24
+ cache_write_1h: 0, reasoning: 0, cost: Cost.zero)
25
+ super
26
+ end
27
+
28
+ def self.zero = new
29
+
30
+ def self.from_h(hash)
31
+ h = (hash || {}).transform_keys(&:to_s)
32
+ c = (h["cost"] || {}).transform_keys(&:to_s)
33
+ new(input: h.fetch("input", 0).to_i, output: h.fetch("output", 0).to_i,
34
+ cache_read: h.fetch("cache_read", 0).to_i, cache_write: h.fetch("cache_write", 0).to_i,
35
+ cache_write_1h: h.fetch("cache_write_1h", 0).to_i,
36
+ reasoning: h.fetch("reasoning", 0).to_i,
37
+ cost: Cost.new(input: c.fetch("input", 0).to_f, output: c.fetch("output", 0).to_f,
38
+ cache_read: c.fetch("cache_read", 0).to_f,
39
+ cache_write: c.fetch("cache_write", 0).to_f,
40
+ total: c.fetch("total", 0).to_f))
41
+ end
42
+
43
+ def total_tokens = input + output + cache_read + cache_write
44
+
45
+ # A copy with cost computed from per-million-token rates. The 1h cache-write
46
+ # slice bills at the :cache_write_1h rate when given, else at twice the
47
+ # input rate, matching extended-retention pricing.
48
+ def with_cost(rates)
49
+ long = [cache_write_1h, cache_write].min
50
+ short = cache_write - long
51
+ computed = {
52
+ input: rate(rates, :input) * input,
53
+ output: rate(rates, :output) * output,
54
+ cache_read: rate(rates, :cache_read) * cache_read,
55
+ cache_write: (rate(rates, :cache_write) * short) + (long_write_rate(rates) * long)
56
+ }
57
+ with(cost: Cost.new(**computed, total: computed.values.sum))
58
+ end
59
+
60
+ def +(other)
61
+ self.class.new(input: input + other.input, output: output + other.output,
62
+ cache_read: cache_read + other.cache_read,
63
+ cache_write: cache_write + other.cache_write,
64
+ cache_write_1h: cache_write_1h + other.cache_write_1h,
65
+ reasoning: reasoning + other.reasoning,
66
+ cost: cost + other.cost)
67
+ end
68
+
69
+ def to_h = super.merge(cost: cost.to_h)
70
+
71
+ private
72
+
73
+ def rate(rates, key) = rates.fetch(key, 0).to_f / 1_000_000
74
+
75
+ def long_write_rate(rates)
76
+ rates.key?(:cache_write_1h) ? rate(rates, :cache_write_1h) : rate(rates, :input) * 2
77
+ end
78
+ end
79
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.0.3"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Workspace
5
+ # Documents in the host's own database, through a model class the host
6
+ # supplies, optionally scoped (per session, per tenant). Not auto-required;
7
+ # load it with require "mistri/workspace/active_record".
8
+ #
9
+ # The model needs a path column and a content column, unique on path
10
+ # within the scope. A migration to copy:
11
+ #
12
+ # create_table :mistri_documents do |t|
13
+ # t.string :session_id, null: false
14
+ # t.string :path, null: false, limit: 512
15
+ # t.text :content, size: :medium, null: false
16
+ # t.timestamps
17
+ # end
18
+ # add_index :mistri_documents, [:session_id, :path], unique: true
19
+ class ActiveRecord
20
+ def initialize(model, scope: {})
21
+ @model = model
22
+ @scope = scope
23
+ end
24
+
25
+ def read(path)
26
+ @model.where(**@scope, path: path.to_s).pick(:content)
27
+ end
28
+
29
+ def write(path, content)
30
+ record = @model.find_or_initialize_by(**@scope, path: path.to_s)
31
+ record.content = content.to_s
32
+ record.save!
33
+ nil
34
+ end
35
+
36
+ def delete(path)
37
+ @model.where(**@scope, path: path.to_s).delete_all
38
+ nil
39
+ end
40
+
41
+ def list(prefix = nil)
42
+ paths = @model.where(**@scope).pluck(:path).sort
43
+ prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Mistri
6
+ module Workspace
7
+ # Documents as files under one root. Every path resolves inside the root
8
+ # or raises, so a model-supplied "../../etc/passwd" cannot escape.
9
+ class Directory
10
+ def initialize(root)
11
+ @root = File.expand_path(root)
12
+ FileUtils.mkdir_p(@root)
13
+ end
14
+
15
+ def read(path)
16
+ full = resolve(path)
17
+ File.exist?(full) ? File.read(full) : nil
18
+ end
19
+
20
+ def write(path, content)
21
+ full = resolve(path)
22
+ FileUtils.mkdir_p(File.dirname(full))
23
+ File.write(full, content.to_s)
24
+ nil
25
+ end
26
+
27
+ def delete(path)
28
+ full = resolve(path)
29
+ FileUtils.rm_f(full)
30
+ nil
31
+ end
32
+
33
+ def list(prefix = nil)
34
+ base = @root.length + 1
35
+ paths = Dir.glob(File.join(@root, "**", "*")).select { |f| File.file?(f) }
36
+ .map { |f| f[base..] }.sort
37
+ prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
38
+ end
39
+
40
+ private
41
+
42
+ def resolve(path)
43
+ full = File.expand_path(path.to_s, @root)
44
+ unless full == @root || full.start_with?("#{@root}#{File::SEPARATOR}")
45
+ raise SchemaError, "path escapes the workspace: #{path}"
46
+ end
47
+
48
+ full
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # The document store agents work in. A workspace maps paths to text and can
5
+ # live anywhere: memory for tests and ephemeral runs, a directory when disk
6
+ # exists, the host's database when it does not. The file tools bind to this
7
+ # port, so fuzzy-editing a MySQL row works exactly like editing a file.
8
+ #
9
+ # A backend implements read(path) -> String or nil, write(path, content),
10
+ # delete(path), and list(prefix = nil) -> [paths].
11
+ module Workspace
12
+ class Memory
13
+ def initialize
14
+ @documents = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def read(path)
19
+ @mutex.synchronize { @documents[path.to_s] }
20
+ end
21
+
22
+ def write(path, content)
23
+ @mutex.synchronize { @documents[path.to_s] = content.to_s.dup.freeze }
24
+ nil
25
+ end
26
+
27
+ def delete(path)
28
+ @mutex.synchronize { @documents.delete(path.to_s) }
29
+ nil
30
+ end
31
+
32
+ def list(prefix = nil)
33
+ @mutex.synchronize do
34
+ keys = @documents.keys.sort
35
+ prefix ? keys.select { |key| key.start_with?(prefix.to_s) } : keys
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Workspace
5
+ # One document living wherever the host says: a column on a record, a
6
+ # cache key, anything readable and writable. This is the shape of an
7
+ # agent that edits one page:
8
+ #
9
+ # workspace = Mistri::Workspace::Single.new(
10
+ # path: "page.html",
11
+ # read: -> { page.reload.draft_html },
12
+ # write: ->(html) { page.update!(draft_html: html) }
13
+ # )
14
+ #
15
+ # The document tools then read and edit that column like any document.
16
+ class Single
17
+ def initialize(read:, write:, path: "document")
18
+ @path = path.to_s
19
+ @read = read
20
+ @write = write
21
+ end
22
+
23
+ def read(path)
24
+ path.to_s == @path ? @read.call : nil
25
+ end
26
+
27
+ def write(path, content)
28
+ raise SchemaError, "this workspace holds only #{@path.inspect}" unless path.to_s == @path
29
+
30
+ @write.call(content.to_s)
31
+ nil
32
+ end
33
+
34
+ def delete(path)
35
+ if path.to_s == @path
36
+ raise SchemaError,
37
+ "#{@path.inspect} cannot be deleted, only rewritten"
38
+ end
39
+
40
+ nil
41
+ end
42
+
43
+ def list(prefix = nil)
44
+ prefix.nil? || @path.start_with?(prefix.to_s) ? [@path] : []
45
+ end
46
+ end
47
+ end
48
+ end
data/lib/mistri.rb CHANGED
@@ -1,7 +1,96 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "mistri/version"
4
+ require_relative "mistri/errors"
5
+ require_relative "mistri/stop_reason"
6
+ require_relative "mistri/usage"
7
+ require_relative "mistri/models"
8
+ require_relative "mistri/tool_call"
9
+ require_relative "mistri/content"
10
+ require_relative "mistri/message"
11
+ require_relative "mistri/event"
12
+ require_relative "mistri/abort_signal"
13
+ require_relative "mistri/sse"
14
+ require_relative "mistri/partial_json"
15
+ require_relative "mistri/transport"
16
+ require_relative "mistri/schema"
17
+ require_relative "mistri/edit"
18
+ require_relative "mistri/tool_context"
19
+ require_relative "mistri/tool_result"
20
+ require_relative "mistri/tool"
21
+ require_relative "mistri/workspace/memory"
22
+ require_relative "mistri/workspace/directory"
23
+ require_relative "mistri/workspace/single"
24
+ require_relative "mistri/memory"
25
+ require_relative "mistri/tools"
26
+ require_relative "mistri/skill"
27
+ require_relative "mistri/skills"
28
+ require_relative "mistri/tool_executor"
29
+ require_relative "mistri/budget"
30
+ require_relative "mistri/retry_policy"
31
+ require_relative "mistri/reminder"
32
+ require_relative "mistri/compaction"
33
+ require_relative "mistri/stores/memory"
34
+ require_relative "mistri/stores/jsonl"
35
+ require_relative "mistri/session"
36
+ require_relative "mistri/compactor"
37
+ require_relative "mistri/result"
38
+ require_relative "mistri/agent"
39
+ require_relative "mistri/sub_agent"
40
+ require_relative "mistri/mcp"
41
+ require_relative "mistri/sinks/action_cable"
42
+ require_relative "mistri/sinks/sse"
43
+ require_relative "mistri/sinks/coalesced"
44
+ require_relative "mistri/providers/fake"
45
+ require_relative "mistri/providers/anthropic"
46
+ require_relative "mistri/providers/openai"
47
+ require_relative "mistri/providers/gemini"
4
48
 
5
49
  # Mistri (مستری): the fixer. An agent harness for Ruby applications.
6
50
  module Mistri
51
+ PROVIDERS = { anthropic: Providers::Anthropic, openai: Providers::OpenAI,
52
+ gemini: Providers::Gemini }.freeze
53
+ API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
54
+ gemini: "GEMINI_API_KEY" }.freeze
55
+
56
+ module_function
57
+
58
+ # Build a provider for a model, inferring which one from the model id and
59
+ # reading its key from the environment unless one is passed.
60
+ #
61
+ # Mistri.provider("claude-opus-4-8")
62
+ # Mistri.provider("gpt-5.5", api_key: key, reasoning: { effort: "high" })
63
+ def provider(model, api_key: nil, **)
64
+ name = provider_name(model)
65
+ klass = PROVIDERS.fetch(name) { raise ConfigurationError, "no provider for #{model.inspect}" }
66
+ key = api_key || ENV.fetch(API_KEY_ENV.fetch(name), nil)
67
+ raise ConfigurationError, "no API key for #{name}" if key.to_s.empty?
68
+
69
+ klass.new(api_key: key, model: model, **)
70
+ end
71
+
72
+ # Build an agent for a model in one call: infers and constructs the provider,
73
+ # then wraps it in the loop.
74
+ #
75
+ # agent = Mistri.agent("claude-opus-4-8", tools: [weather], system: "Be brief.")
76
+ # agent.run("Weather in Lahore?") { |event| ... }
77
+ def agent(model, api_key: nil, provider_options: {}, **agent_options)
78
+ built = provider(model, api_key: api_key, **provider_options)
79
+ Agent.new(provider: built, **agent_options)
80
+ end
81
+
82
+ # Catalogued models infer directly; unknown ids fall back to the id prefix,
83
+ # so a brand-new model works before it is catalogued.
84
+ def provider_name(model)
85
+ Models.find(model)&.provider || infer_provider_name(model)
86
+ end
87
+
88
+ def infer_provider_name(model)
89
+ case model.to_s
90
+ when /\Aclaude/ then :anthropic
91
+ when /\A(gpt|o\d|chatgpt)/ then :openai
92
+ when /\Agemini/ then :gemini
93
+ else raise ConfigurationError, "cannot infer a provider from #{model.inspect}"
94
+ end
95
+ end
7
96
  end