mistri 0.4.1 → 0.6.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../workspace"
4
+
3
5
  module Mistri
4
6
  module Workspace
5
7
  # Documents in the host's own database, through a model class the host
6
8
  # supplies, optionally scoped (per session, per tenant). Not auto-required;
7
9
  # load it with require "mistri/workspace/active_record".
8
10
  #
9
- # The model needs a path column and a content column, unique on path
10
- # within the scope. A migration to copy:
11
+ # The model needs a non-null primary key plus non-null path, content, and
12
+ # scope columns, with a unique index whose columns are exactly the scope
13
+ # plus path. Atomic edits use that index plus a short row-locking
14
+ # transaction; no lock_version column is required. A migration to copy:
11
15
  #
12
16
  # create_table :mistri_documents do |t|
13
17
  # t.string :session_id, null: false
@@ -17,9 +21,24 @@ module Mistri
17
21
  # end
18
22
  # add_index :mistri_documents, [:session_id, :path], unique: true
19
23
  class ActiveRecord
24
+ MYSQL_ADAPTERS = %w[Mysql2 Trilogy].freeze
25
+ private_constant :MYSQL_ADAPTERS
26
+
20
27
  def initialize(model, scope: {})
28
+ raise ArgumentError, "scope must be a Hash" unless scope.is_a?(Hash)
29
+
30
+ names = scope.keys.map(&:to_s)
31
+ if names.uniq.length != names.length
32
+ raise ArgumentError, "scope keys must name distinct columns"
33
+ end
34
+
35
+ reserved = names & %w[path content]
36
+ unless reserved.empty?
37
+ raise ArgumentError, "scope cannot contain #{reserved.map(&:inspect).join(" or ")}"
38
+ end
39
+
21
40
  @model = model
22
- @scope = scope
41
+ @scope = own_scope(scope)
23
42
  end
24
43
 
25
44
  def read(path)
@@ -33,6 +52,38 @@ module Mistri
33
52
  nil
34
53
  end
35
54
 
55
+ def atomic_writes?
56
+ return @atomic_writes if defined?(@atomic_writes)
57
+
58
+ @atomic_writes = atomic_storage?
59
+ end
60
+
61
+ def snapshot(path)
62
+ verify_atomic_context!
63
+ content = relation(path).pick(:content)
64
+ content.nil? ? nil : Snapshot.for(content.to_s)
65
+ end
66
+
67
+ def compare_and_write(path, content, expected_revision:)
68
+ verify_atomic_context!
69
+ key = path.to_s
70
+ return create_if_absent(key, content) if expected_revision.nil?
71
+
72
+ @model.transaction do
73
+ record = @model.lock.find_by(**@scope, path: key)
74
+ actual = record && Snapshot.for(record.content.to_s).revision
75
+ unless actual == expected_revision
76
+ raise WorkspaceConflictError.new(
77
+ key, expected_revision:, actual_revision: actual
78
+ )
79
+ end
80
+
81
+ record.content = content.to_s
82
+ record.save!
83
+ committed_snapshot(record, key)
84
+ end
85
+ end
86
+
36
87
  def delete(path)
37
88
  @model.where(**@scope, path: path.to_s).delete_all
38
89
  nil
@@ -42,6 +93,135 @@ module Mistri
42
93
  paths = @model.where(**@scope).pluck(:path).sort
43
94
  prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
44
95
  end
96
+
97
+ private
98
+
99
+ def verify_atomic_context!
100
+ unless atomic_writes?
101
+ adapter = @model.connection.adapter_name
102
+ raise ConfigurationError,
103
+ "atomic Active Record workspace writes require PostgreSQL or InnoDB, " \
104
+ "a matching non-null primary key, non-null scope/path/content columns, " \
105
+ "and an exact unique scope/path key; #{@model.table_name.inspect} on " \
106
+ "#{adapter} does not qualify"
107
+ end
108
+ return unless @model.connection.transaction_open?
109
+
110
+ raise ConfigurationError,
111
+ "atomic Active Record workspace operations cannot join an open transaction"
112
+ end
113
+
114
+ def relation(path) = @model.where(**@scope, path: path.to_s)
115
+
116
+ def create_if_absent(path, content)
117
+ @model.transaction do
118
+ record = @model.create!(**@scope, path:, content: content.to_s)
119
+ committed_snapshot(record, path)
120
+ end
121
+ rescue ::ActiveRecord::RecordNotUnique
122
+ raise unless relation(path).exists?
123
+
124
+ actual = snapshot(path)&.revision
125
+ raise WorkspaceConflictError.new(path, actual_revision: actual)
126
+ end
127
+
128
+ def atomic_storage?
129
+ adapter = @model.connection.adapter_name
130
+ supported = adapter == "PostgreSQL" ||
131
+ (MYSQL_ADAPTERS.include?(adapter) && innodb_table?)
132
+ supported && atomic_schema?
133
+ end
134
+
135
+ def innodb_table?
136
+ connection = @model.connection
137
+ database, table = mysql_table_coordinates(connection)
138
+ sql = "SELECT ENGINE FROM information_schema.TABLES " \
139
+ "WHERE TABLE_SCHEMA = #{connection.quote(database)} " \
140
+ "AND TABLE_NAME = #{connection.quote(table)}"
141
+ connection.select_value(sql, "SCHEMA")&.casecmp?("InnoDB") == true
142
+ end
143
+
144
+ def mysql_table_coordinates(connection)
145
+ names = @model.table_name.to_s.split(".", 2)
146
+ return names if names.length == 2
147
+
148
+ [connection.current_database, names.first]
149
+ end
150
+
151
+ def atomic_schema?
152
+ connection = @model.connection
153
+ columns = connection.columns(@model.table_name).to_h { |column| [column.name, column] }
154
+ primary_columns = Array(@model.primary_key).compact.map(&:to_s)
155
+ catalog_primary = Array(connection.primary_keys(@model.table_name)).map(&:to_s)
156
+ return false unless primary_columns.sort == catalog_primary.sort
157
+ return false if primary_columns.empty? || !singular_scope?
158
+
159
+ required_columns = (@scope.keys.map(&:to_s) + %w[path content]).uniq
160
+ required_columns.concat(primary_columns).uniq!
161
+ return false unless non_null_columns?(columns, required_columns)
162
+
163
+ identity = (@scope.keys.map(&:to_s) + ["path"]).uniq.sort
164
+ return true if catalog_primary.sort == identity
165
+
166
+ exact_unique_identity?(connection, identity)
167
+ end
168
+
169
+ def exact_unique_identity?(connection, identity)
170
+ connection.indexes(@model.table_name).any? do |index|
171
+ valid = !index.respond_to?(:valid?) || index.valid?
172
+ columns = Array(index.columns).map(&:to_s).sort
173
+ index.unique && valid && !index.where && columns == identity
174
+ end
175
+ end
176
+
177
+ def non_null_columns?(columns, required)
178
+ required.all? do |name|
179
+ column = columns[name]
180
+ column && !column.null
181
+ end
182
+ end
183
+
184
+ def committed_snapshot(record, path)
185
+ identity = Array(@model.primary_key).compact.to_h do |column|
186
+ attributes = record.attributes
187
+ value = attributes.fetch(column.to_s) { attributes.fetch(column.to_sym) }
188
+ [column.to_sym, value]
189
+ end
190
+ if identity.empty?
191
+ raise ConfigurationError, "atomic Active Record workspace needs a primary key"
192
+ end
193
+
194
+ content = @model.where(**identity, **@scope, path:).pick(:content)
195
+ if content.nil?
196
+ raise SchemaError, "document #{path.inspect} changed identity during its write"
197
+ end
198
+
199
+ Snapshot.for(content.to_s)
200
+ end
201
+
202
+ def singular_scope?
203
+ @scope.values.all? do |value|
204
+ case value
205
+ when String, Symbol, Numeric, TrueClass, FalseClass, Time then true
206
+ else defined?(Date) && value.is_a?(Date)
207
+ end
208
+ end
209
+ end
210
+
211
+ def own_scope(value)
212
+ case value
213
+ when Hash
214
+ value.to_h { |key, item| [own_scope(key), own_scope(item)] }.freeze
215
+ when Array
216
+ value.map { |item| own_scope(item) }.freeze
217
+ when Range
218
+ Range.new(own_scope(value.begin), own_scope(value.end), value.exclude_end?).freeze
219
+ when String
220
+ String.new(value).freeze
221
+ else
222
+ value
223
+ end
224
+ end
45
225
  end
46
226
  end
47
227
  end
@@ -1,15 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "../workspace"
4
5
 
5
6
  module Mistri
6
7
  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.
8
+ # Documents as ordinary files under a host-controlled root. Model-supplied
9
+ # paths cannot escape lexically or traverse an existing symlink. The host
10
+ # must keep the root and tree stable for the instance's lifetime; this is
11
+ # not an OS sandbox against concurrent filesystem mutation.
9
12
  class Directory
10
13
  def initialize(root)
11
- @root = File.expand_path(root)
12
- FileUtils.mkdir_p(@root)
14
+ expanded = File.expand_path(root)
15
+ FileUtils.mkdir_p(expanded)
16
+ @root = File.realpath(expanded)
17
+ @root_prefix = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
13
18
  end
14
19
 
15
20
  def read(path)
@@ -20,6 +25,7 @@ module Mistri
20
25
  def write(path, content)
21
26
  full = resolve(path)
22
27
  FileUtils.mkdir_p(File.dirname(full))
28
+ full = resolve(path)
23
29
  File.write(full, content.to_s)
24
30
  nil
25
31
  end
@@ -31,9 +37,13 @@ module Mistri
31
37
  end
32
38
 
33
39
  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
40
+ paths = Dir.glob("**/*", base: @root).filter_map do |path|
41
+ full = File.join(@root, path)
42
+ next if traverses_symlink?(full)
43
+ next unless File.file?(full)
44
+
45
+ path
46
+ end.sort
37
47
  prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
38
48
  end
39
49
 
@@ -41,12 +51,22 @@ module Mistri
41
51
 
42
52
  def resolve(path)
43
53
  full = File.expand_path(path.to_s, @root)
44
- unless full == @root || full.start_with?("#{@root}#{File::SEPARATOR}")
54
+ unless full == @root || full.start_with?(@root_prefix)
45
55
  raise SchemaError, "path escapes the workspace: #{path}"
46
56
  end
57
+ raise SchemaError, "path traverses a symlink: #{path}" if traverses_symlink?(full)
47
58
 
48
59
  full
49
60
  end
61
+
62
+ def traverses_symlink?(full)
63
+ current = @root
64
+ relative = full.delete_prefix(@root).delete_prefix(File::SEPARATOR)
65
+ relative.split(File::SEPARATOR).any? do |part|
66
+ current = File.join(current, part)
67
+ File.symlink?(current)
68
+ end
69
+ end
50
70
  end
51
71
  end
52
72
  end
@@ -1,14 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../workspace"
4
+
3
5
  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
6
  module Workspace
7
+ # A mutex-protected ephemeral document map with atomic conditional writes.
12
8
  class Memory
13
9
  def initialize
14
10
  @documents = {}
@@ -16,14 +12,39 @@ module Mistri
16
12
  end
17
13
 
18
14
  def read(path)
19
- @mutex.synchronize { @documents[path.to_s] }
15
+ @mutex.synchronize { @documents[path.to_s]&.dup }
20
16
  end
21
17
 
22
18
  def write(path, content)
23
- @mutex.synchronize { @documents[path.to_s] = content.to_s.dup.freeze }
19
+ @mutex.synchronize { @documents[path.to_s] = own(content) }
24
20
  nil
25
21
  end
26
22
 
23
+ def atomic_writes? = true
24
+
25
+ def snapshot(path)
26
+ @mutex.synchronize do
27
+ content = @documents[path.to_s]
28
+ content && Snapshot.for(content)
29
+ end
30
+ end
31
+
32
+ def compare_and_write(path, content, expected_revision:)
33
+ @mutex.synchronize do
34
+ key = path.to_s
35
+ current = @documents[key]
36
+ actual = current && Snapshot.for(current).revision
37
+ unless actual == expected_revision
38
+ raise WorkspaceConflictError.new(
39
+ key, expected_revision:, actual_revision: actual
40
+ )
41
+ end
42
+
43
+ @documents[key] = own(content)
44
+ Snapshot.for(@documents[key])
45
+ end
46
+ end
47
+
27
48
  def delete(path)
28
49
  @mutex.synchronize { @documents.delete(path.to_s) }
29
50
  nil
@@ -35,6 +56,10 @@ module Mistri
35
56
  prefix ? keys.select { |key| key.start_with?(prefix.to_s) } : keys
36
57
  end
37
58
  end
59
+
60
+ private
61
+
62
+ def own(content) = String.new(content.to_s).freeze
38
63
  end
39
64
  end
40
65
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../workspace"
4
+
3
5
  module Mistri
4
6
  module Workspace
5
7
  # One document living wherever the host says: a column on a record, a
@@ -9,15 +11,26 @@ module Mistri
9
11
  # workspace = Mistri::Workspace::Single.new(
10
12
  # path: "page.html",
11
13
  # read: -> { page.reload.draft_html },
12
- # write: ->(html) { page.update!(draft_html: html) }
14
+ # write: ->(html) { page.update!(draft_html: html) },
15
+ # synchronize: lambda do |&operation|
16
+ # raise "outer transaction" if page.class.connection.transaction_open?
17
+ # page.with_lock(&operation)
18
+ # end
13
19
  # )
14
20
  #
15
21
  # The document tools then read and edit that column like any document.
22
+ # synchronize: is optional; without it, the legacy read/write port cannot
23
+ # protect an edit from another process writing between those two calls.
16
24
  class Single
17
- def initialize(read:, write:, path: "document")
18
- @path = path.to_s
25
+ def initialize(read:, write:, path: "document", synchronize: nil)
26
+ unless synchronize.nil? || synchronize.respond_to?(:call)
27
+ raise ArgumentError, "synchronize must be callable"
28
+ end
29
+
30
+ @path = String.new(path.to_s).freeze
19
31
  @read = read
20
32
  @write = write
33
+ @synchronize = synchronize
21
34
  end
22
35
 
23
36
  def read(path)
@@ -25,12 +38,44 @@ module Mistri
25
38
  end
26
39
 
27
40
  def write(path, content)
28
- raise SchemaError, "this workspace holds only #{@path.inspect}" unless path.to_s == @path
41
+ verify_path!(path)
29
42
 
30
- @write.call(content.to_s)
43
+ synchronize { @write.call(content.to_s) }
31
44
  nil
32
45
  end
33
46
 
47
+ def atomic_writes? = !@synchronize.nil?
48
+
49
+ def snapshot(path)
50
+ return nil unless path.to_s == @path
51
+
52
+ content = @read.call
53
+ content.nil? ? nil : Snapshot.for(content.to_s)
54
+ end
55
+
56
+ def compare_and_write(path, content, expected_revision:)
57
+ verify_path!(path)
58
+ unless atomic_writes?
59
+ raise ConfigurationError, "Single needs synchronize: for atomic writes"
60
+ end
61
+
62
+ synchronize do
63
+ current = @read.call
64
+ actual = current.nil? ? nil : Snapshot.for(current.to_s).revision
65
+ unless actual == expected_revision
66
+ raise WorkspaceConflictError.new(
67
+ @path, expected_revision:, actual_revision: actual
68
+ )
69
+ end
70
+
71
+ @write.call(content.to_s)
72
+ committed = @read.call
73
+ raise SchemaError, "#{@path.inspect} disappeared after write" if committed.nil?
74
+
75
+ Snapshot.for(committed.to_s)
76
+ end
77
+ end
78
+
34
79
  def delete(path)
35
80
  if path.to_s == @path
36
81
  raise SchemaError,
@@ -43,6 +88,18 @@ module Mistri
43
88
  def list(prefix = nil)
44
89
  prefix.nil? || @path.start_with?(prefix.to_s) ? [@path] : []
45
90
  end
91
+
92
+ private
93
+
94
+ def verify_path!(path)
95
+ return if path.to_s == @path
96
+
97
+ raise SchemaError, "this workspace holds only #{@path.inspect}"
98
+ end
99
+
100
+ def synchronize(&operation)
101
+ @synchronize ? @synchronize.call(&operation) : operation.call
102
+ end
46
103
  end
47
104
  end
48
105
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "errors"
5
+
6
+ module Mistri
7
+ # The host-neutral document boundary. Every workspace supports ordinary
8
+ # read/write/delete/list; atomic_writes? opts into snapshot and conditional
9
+ # write semantics that remain correct across processes when its backend does.
10
+ module Workspace
11
+ MAX_REVISION_BYTES = 256
12
+ private_constant :MAX_REVISION_BYTES
13
+
14
+ # One immutable document representation and the opaque token that binds a
15
+ # later conditional write to these exact bytes.
16
+ Snapshot = Data.define(:content, :revision) do
17
+ def initialize(content:, revision:)
18
+ raise ArgumentError, "snapshot content must be a String" unless content.is_a?(String)
19
+ raise ArgumentError, "snapshot revision must be a String" unless revision.is_a?(String)
20
+
21
+ owned_content = String.new(content)
22
+ owned_revision = String.new(revision)
23
+ unless !owned_revision.empty? && owned_revision.bytesize <= MAX_REVISION_BYTES
24
+ raise ArgumentError,
25
+ "snapshot revision must be 1-#{MAX_REVISION_BYTES} bytes"
26
+ end
27
+
28
+ super(content: owned_content.freeze, revision: owned_revision.freeze)
29
+ end
30
+
31
+ def self.for(content)
32
+ raise ArgumentError, "snapshot content must be a String" unless content.is_a?(String)
33
+
34
+ owned = String.new(content)
35
+ new(content: owned, revision: Digest::SHA256.hexdigest(owned.b))
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/mistri.rb CHANGED
@@ -5,19 +5,23 @@ require_relative "mistri/errors"
5
5
  require_relative "mistri/stop_reason"
6
6
  require_relative "mistri/usage"
7
7
  require_relative "mistri/models"
8
+ require_relative "mistri/tool_arguments"
8
9
  require_relative "mistri/tool_call"
9
10
  require_relative "mistri/content"
10
11
  require_relative "mistri/message"
11
12
  require_relative "mistri/event"
13
+ require_relative "mistri/event_delivery"
12
14
  require_relative "mistri/abort_signal"
13
15
  require_relative "mistri/sse"
14
16
  require_relative "mistri/partial_json"
15
17
  require_relative "mistri/transport"
16
18
  require_relative "mistri/schema"
19
+ require_relative "mistri/task_output"
17
20
  require_relative "mistri/edit"
18
21
  require_relative "mistri/tool_context"
19
22
  require_relative "mistri/tool_result"
20
23
  require_relative "mistri/tool"
24
+ require_relative "mistri/workspace"
21
25
  require_relative "mistri/workspace/memory"
22
26
  require_relative "mistri/workspace/directory"
23
27
  require_relative "mistri/workspace/single"
@@ -34,10 +38,15 @@ require_relative "mistri/compaction"
34
38
  require_relative "mistri/stores/memory"
35
39
  require_relative "mistri/stores/jsonl"
36
40
  require_relative "mistri/session"
41
+ require_relative "mistri/child"
42
+ require_relative "mistri/locks"
43
+ require_relative "mistri/console"
44
+ require_relative "mistri/dispatchers"
37
45
  require_relative "mistri/compactor"
38
46
  require_relative "mistri/result"
39
47
  require_relative "mistri/agent"
40
48
  require_relative "mistri/sub_agent"
49
+ require_relative "mistri/spawner"
41
50
  require_relative "mistri/mcp"
42
51
  require_relative "mistri/sinks/action_cable"
43
52
  require_relative "mistri/sinks/sse"
@@ -49,18 +58,25 @@ require_relative "mistri/providers/gemini"
49
58
 
50
59
  # Mistri (مستری): the fixer. An agent harness for Ruby applications.
51
60
  module Mistri
61
+ private_constant :ToolArguments
62
+
52
63
  PROVIDERS = { anthropic: Providers::Anthropic, openai: Providers::OpenAI,
53
64
  gemini: Providers::Gemini }.freeze
54
65
  API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
55
66
  gemini: "GEMINI_API_KEY" }.freeze
56
67
 
68
+ # The configured lock adapter, nil until a host sets one. See Locks.
69
+ class << self
70
+ attr_accessor :locks
71
+ end
72
+
57
73
  module_function
58
74
 
59
75
  # Build a provider for a model, inferring which one from the model id and
60
76
  # reading its key from the environment unless one is passed.
61
77
  #
62
78
  # Mistri.provider("claude-opus-4-8")
63
- # Mistri.provider("gpt-5.5", api_key: key, reasoning: { effort: "high" })
79
+ # Mistri.provider("gpt-5.6", api_key: key, reasoning: { effort: "high" })
64
80
  def provider(model, api_key: nil, **)
65
81
  name = provider_name(model)
66
82
  klass = PROVIDERS.fetch(name) { raise ConfigurationError, "no provider for #{model.inspect}" }
data/mistri.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/mistri/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "mistri"
7
+ spec.version = Mistri::VERSION
8
+ spec.authors = ["Muhammad Ahmed Cheema"]
9
+ spec.summary = "The agent harness for Ruby applications."
10
+ spec.description = "Mistri (مستری) is the fixer: an agent harness that lives inside your " \
11
+ "app. Durable sessions in your own store, streaming, tools, " \
12
+ "fire-and-forget human approval, steering, compaction, structured " \
13
+ "output, skills, and sub-agents, across Anthropic, OpenAI, and " \
14
+ "Gemini, with zero runtime dependencies."
15
+ spec.homepage = "https://mistri.sh"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2"
18
+
19
+ spec.metadata["source_code_uri"] = "https://github.com/mcheemaa/mistri"
20
+ spec.metadata["changelog_uri"] = "https://github.com/mcheemaa/mistri/blob/main/CHANGELOG.md"
21
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+ spec.metadata["documentation_uri"] =
24
+ "https://github.com/mcheemaa/mistri/blob/v#{spec.version}/docs/README.md"
25
+ spec.metadata["bug_tracker_uri"] = "https://github.com/mcheemaa/mistri/issues"
26
+
27
+ # The .tt generator templates must ship, or rails g mistri:install breaks.
28
+ public_files = Dir["lib/**/*.{rb,tt}", "docs/**/*.md", "assets/**/*", "examples/*.rb"]
29
+ .select { |path| File.file?(path) }
30
+ root_files = %w[CHANGELOG.md CONTRIBUTING.md LICENSE NOTICE README.md SECURITY.md
31
+ UPGRADING.md mistri.gemspec]
32
+ spec.files = (public_files + root_files).sort
33
+ spec.require_paths = ["lib"]
34
+ end