mistri 0.5.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +469 -4
- data/CONTRIBUTING.md +52 -0
- data/README.md +289 -385
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/agent.rb +575 -55
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +72 -16
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +34 -11
- data/lib/mistri/console.rb +28 -7
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +14 -12
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +24 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks.rb +3 -3
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +42 -8
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +26 -10
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +1 -1
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +560 -48
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/spawner.rb +111 -61
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +1 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +124 -87
- data/lib/mistri/task_output.rb +24 -6
- data/lib/mistri/tool.rb +93 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +4 -2
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +6 -1
- data/mistri.gemspec +34 -0
- metadata +31 -3
data/lib/mistri/usage.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
# Token accounting for an assistant turn, and the dollar cost of those tokens.
|
|
5
7
|
#
|
|
@@ -9,37 +11,77 @@ module Mistri
|
|
|
9
11
|
# of `cache_write` held for an hour, which bills at twice the input rate.
|
|
10
12
|
class Usage < Data.define(:input, :output, :cache_read, :cache_write,
|
|
11
13
|
:cache_write_1h, :reasoning, :cost)
|
|
14
|
+
# Dollar amounts plus whether every contributing token had known pricing.
|
|
15
|
+
# Known stays outside the released Data shape so pattern matching and
|
|
16
|
+
# positional construction remain compatible.
|
|
12
17
|
Cost = Data.define(:input, :output, :cache_read, :cache_write, :total) do
|
|
13
|
-
def
|
|
18
|
+
def initialize(input:, output:, cache_read:, cache_write:, total:, known: true)
|
|
19
|
+
@known = known == true
|
|
20
|
+
super(input:, output:, cache_read:, cache_write:, total:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.zero
|
|
24
|
+
new(input: 0.0, output: 0.0, cache_read: 0.0, cache_write: 0.0,
|
|
25
|
+
total: 0.0, known: true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.unknown
|
|
29
|
+
new(input: 0.0, output: 0.0, cache_read: 0.0, cache_write: 0.0,
|
|
30
|
+
total: 0.0, known: false)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def known? = defined?(@known) ? @known : true
|
|
34
|
+
|
|
35
|
+
def with(**changes)
|
|
36
|
+
self.class.new(**to_h, **changes)
|
|
37
|
+
end
|
|
14
38
|
|
|
15
39
|
def +(other)
|
|
16
40
|
self.class.new(input: input + other.input, output: output + other.output,
|
|
17
41
|
cache_read: cache_read + other.cache_read,
|
|
18
42
|
cache_write: cache_write + other.cache_write,
|
|
19
|
-
total: total + other.total)
|
|
43
|
+
total: total + other.total, known: known? && other.known?)
|
|
20
44
|
end
|
|
45
|
+
|
|
46
|
+
def ==(other) = super && known? == other.known?
|
|
47
|
+
|
|
48
|
+
alias_method :eql?, :==
|
|
49
|
+
|
|
50
|
+
def hash = [super, known?].hash
|
|
51
|
+
|
|
52
|
+
def to_h = super.merge(known: known?)
|
|
53
|
+
|
|
54
|
+
def _dump(_level) = JSON.generate(to_h)
|
|
55
|
+
|
|
56
|
+
def self._load(payload) = new(**JSON.parse(payload, symbolize_names: true))
|
|
21
57
|
end
|
|
22
58
|
|
|
23
59
|
def initialize(input: 0, output: 0, cache_read: 0, cache_write: 0,
|
|
24
|
-
cache_write_1h: 0, reasoning: 0, cost: Cost.
|
|
60
|
+
cache_write_1h: 0, reasoning: 0, cost: Cost.unknown)
|
|
25
61
|
super
|
|
26
62
|
end
|
|
27
63
|
|
|
28
|
-
def self.zero = new
|
|
64
|
+
def self.zero = new(cost: Cost.zero)
|
|
29
65
|
|
|
30
66
|
def self.from_h(hash)
|
|
31
67
|
h = (hash || {}).transform_keys(&:to_s)
|
|
32
68
|
c = (h["cost"] || {}).transform_keys(&:to_s)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
counts = { input: h.fetch("input", 0).to_i, output: h.fetch("output", 0).to_i,
|
|
70
|
+
cache_read: h.fetch("cache_read", 0).to_i,
|
|
71
|
+
cache_write: h.fetch("cache_write", 0).to_i,
|
|
72
|
+
cache_write_1h: h.fetch("cache_write_1h", 0).to_i,
|
|
73
|
+
reasoning: h.fetch("reasoning", 0).to_i }
|
|
74
|
+
amounts = { input: c.fetch("input", 0).to_f, output: c.fetch("output", 0).to_f,
|
|
75
|
+
cache_read: c.fetch("cache_read", 0).to_f,
|
|
76
|
+
cache_write: c.fetch("cache_write", 0).to_f,
|
|
77
|
+
total: c.fetch("total", 0).to_f }
|
|
78
|
+
known = c.fetch("known", false) == true
|
|
79
|
+
known ||= !c.key?("known") && counts.values.all?(&:zero?) && amounts.values.all?(&:zero?)
|
|
80
|
+
new(**counts, cost: Cost.new(**amounts, known:))
|
|
41
81
|
end
|
|
42
82
|
|
|
83
|
+
def prompt_tokens = input + cache_read + cache_write
|
|
84
|
+
|
|
43
85
|
def total_tokens = input + output + cache_read + cache_write
|
|
44
86
|
|
|
45
87
|
# A copy with cost computed from per-million-token rates. The 1h cache-write
|
|
@@ -54,7 +96,9 @@ module Mistri
|
|
|
54
96
|
cache_read: rate(rates, :cache_read) * cache_read,
|
|
55
97
|
cache_write: (rate(rates, :cache_write) * short) + (long_write_rate(rates) * long)
|
|
56
98
|
}
|
|
57
|
-
with(cost: Cost.new(**computed,
|
|
99
|
+
with(cost: Cost.new(**computed,
|
|
100
|
+
total: computed.values.sum,
|
|
101
|
+
known: rates_cover?(rates, short, long)))
|
|
58
102
|
end
|
|
59
103
|
|
|
60
104
|
def +(other)
|
|
@@ -75,5 +119,13 @@ module Mistri
|
|
|
75
119
|
def long_write_rate(rates)
|
|
76
120
|
rates.key?(:cache_write_1h) ? rate(rates, :cache_write_1h) : rate(rates, :input) * 2
|
|
77
121
|
end
|
|
122
|
+
|
|
123
|
+
def rates_cover?(rates, short, long)
|
|
124
|
+
(input.zero? || rates.key?(:input)) &&
|
|
125
|
+
(output.zero? || rates.key?(:output)) &&
|
|
126
|
+
(cache_read.zero? || rates.key?(:cache_read)) &&
|
|
127
|
+
(short.zero? || rates.key?(:cache_write)) &&
|
|
128
|
+
(long.zero? || rates.key?(:cache_write_1h) || rates.key?(:input))
|
|
129
|
+
end
|
|
78
130
|
end
|
|
79
131
|
end
|
data/lib/mistri/version.rb
CHANGED
|
@@ -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
|
|
10
|
-
#
|
|
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
|
|
8
|
-
#
|
|
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
|
-
|
|
12
|
-
FileUtils.mkdir_p(
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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?(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,10 +5,12 @@ 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"
|
|
@@ -19,6 +21,7 @@ require_relative "mistri/edit"
|
|
|
19
21
|
require_relative "mistri/tool_context"
|
|
20
22
|
require_relative "mistri/tool_result"
|
|
21
23
|
require_relative "mistri/tool"
|
|
24
|
+
require_relative "mistri/workspace"
|
|
22
25
|
require_relative "mistri/workspace/memory"
|
|
23
26
|
require_relative "mistri/workspace/directory"
|
|
24
27
|
require_relative "mistri/workspace/single"
|
|
@@ -55,6 +58,8 @@ require_relative "mistri/providers/gemini"
|
|
|
55
58
|
|
|
56
59
|
# Mistri (مستری): the fixer. An agent harness for Ruby applications.
|
|
57
60
|
module Mistri
|
|
61
|
+
private_constant :ToolArguments
|
|
62
|
+
|
|
58
63
|
PROVIDERS = { anthropic: Providers::Anthropic, openai: Providers::OpenAI,
|
|
59
64
|
gemini: Providers::Gemini }.freeze
|
|
60
65
|
API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
|
|
@@ -71,7 +76,7 @@ module Mistri
|
|
|
71
76
|
# reading its key from the environment unless one is passed.
|
|
72
77
|
#
|
|
73
78
|
# Mistri.provider("claude-opus-4-8")
|
|
74
|
-
# Mistri.provider("gpt-5.
|
|
79
|
+
# Mistri.provider("gpt-5.6", api_key: key, reasoning: { effort: "high" })
|
|
75
80
|
def provider(model, api_key: nil, **)
|
|
76
81
|
name = provider_name(model)
|
|
77
82
|
klass = PROVIDERS.fetch(name) { raise ConfigurationError, "no provider for #{model.inspect}" }
|