mistri 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +177 -0
  3. data/NOTICE +9 -0
  4. data/README.md +314 -3
  5. data/lib/generators/mistri/install/install_generator.rb +54 -0
  6. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  7. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  8. data/lib/mistri/abort_signal.rb +63 -0
  9. data/lib/mistri/agent.rb +340 -0
  10. data/lib/mistri/budget.rb +29 -0
  11. data/lib/mistri/compaction.rb +78 -0
  12. data/lib/mistri/compactor.rb +182 -0
  13. data/lib/mistri/content.rb +89 -0
  14. data/lib/mistri/edit.rb +238 -0
  15. data/lib/mistri/errors.rb +94 -0
  16. data/lib/mistri/event.rb +50 -0
  17. data/lib/mistri/memory.rb +26 -0
  18. data/lib/mistri/message.rb +90 -0
  19. data/lib/mistri/models.rb +43 -0
  20. data/lib/mistri/partial_json.rb +210 -0
  21. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  22. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  23. data/lib/mistri/providers/anthropic.rb +106 -0
  24. data/lib/mistri/providers/fake.rb +109 -0
  25. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  26. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  27. data/lib/mistri/providers/gemini.rb +73 -0
  28. data/lib/mistri/providers/openai/assembler.rb +205 -0
  29. data/lib/mistri/providers/openai/serializer.rb +104 -0
  30. data/lib/mistri/providers/openai.rb +72 -0
  31. data/lib/mistri/result.rb +30 -0
  32. data/lib/mistri/retry_policy.rb +47 -0
  33. data/lib/mistri/schema.rb +162 -0
  34. data/lib/mistri/session.rb +124 -0
  35. data/lib/mistri/sinks/action_cable.rb +30 -0
  36. data/lib/mistri/sinks/coalesced.rb +61 -0
  37. data/lib/mistri/sinks/sse.rb +26 -0
  38. data/lib/mistri/skill.rb +15 -0
  39. data/lib/mistri/skills.rb +81 -0
  40. data/lib/mistri/sse.rb +50 -0
  41. data/lib/mistri/stop_reason.rb +25 -0
  42. data/lib/mistri/stores/active_record.rb +47 -0
  43. data/lib/mistri/stores/jsonl.rb +37 -0
  44. data/lib/mistri/stores/memory.rb +22 -0
  45. data/lib/mistri/sub_agent.rb +211 -0
  46. data/lib/mistri/tool.rb +94 -0
  47. data/lib/mistri/tool_call.rb +18 -0
  48. data/lib/mistri/tool_context.rb +15 -0
  49. data/lib/mistri/tool_executor.rb +66 -0
  50. data/lib/mistri/tool_result.rb +23 -0
  51. data/lib/mistri/tools/edit_file.rb +37 -0
  52. data/lib/mistri/tools/find_in_file.rb +36 -0
  53. data/lib/mistri/tools/list_files.rb +16 -0
  54. data/lib/mistri/tools/read_file.rb +38 -0
  55. data/lib/mistri/tools/read_memory.rb +16 -0
  56. data/lib/mistri/tools/update_memory.rb +22 -0
  57. data/lib/mistri/tools/write_file.rb +20 -0
  58. data/lib/mistri/tools.rb +50 -0
  59. data/lib/mistri/transport.rb +187 -0
  60. data/lib/mistri/usage.rb +79 -0
  61. data/lib/mistri/version.rb +3 -1
  62. data/lib/mistri/workspace/active_record.rb +47 -0
  63. data/lib/mistri/workspace/directory.rb +52 -0
  64. data/lib/mistri/workspace/memory.rb +40 -0
  65. data/lib/mistri/workspace/single.rb +48 -0
  66. data/lib/mistri.rb +91 -2
  67. metadata +73 -7
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # A two-channel tool result: content goes to the model, ui goes only to the
5
+ # host. The ui payload rides the tool message and its :tool_result event,
6
+ # persists with the session for transcript re-renders, and never reaches a
7
+ # provider. Return one from a handler when the UI needs more than the model
8
+ # should read or pay for: full query rows behind a compact answer, the
9
+ # updated document behind "saved".
10
+ #
11
+ # Tool.define("edit_page", "Edits the page.") do |args|
12
+ # page = apply(args)
13
+ # Mistri::ToolResult.new(content: "Updated.", ui: { "html" => page })
14
+ # end
15
+ #
16
+ # ui must be JSON-serializable; it is stored and delivered in canonical
17
+ # JSON form (string keys), the same shape a reloaded session reads.
18
+ ToolResult = Data.define(:content, :ui) do
19
+ def initialize(content:, ui: nil)
20
+ super
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ # The model-facing shape is flat {path, old_string, new_string,
8
+ # replace_all} on purpose: it is the shape frontier models are trained on,
9
+ # and nested edit arrays measurably degrade their calls. Failures come
10
+ # back in band with the closest region and its exact difference, so the
11
+ # model's retry is one shot.
12
+ def edit_file(workspace)
13
+ Tool.define("edit_file",
14
+ "Replace an exact snippet of a document. Copy old_string verbatim from " \
15
+ "read_file output including whitespace, without line-number prefixes. " \
16
+ "It must match exactly one place; add surrounding lines to make it " \
17
+ "unique, or set replace_all to change every occurrence.",
18
+ eager_input_streaming: true,
19
+ schema: lambda {
20
+ string :path, "Document path", required: true
21
+ string :old_string, "Exact text to replace (whitespace matters)", required: true
22
+ string :new_string, "Replacement text", required: true
23
+ boolean :replace_all, "Replace every occurrence instead of exactly one"
24
+ }) do |args|
25
+ args = Tools.tolerate(args)
26
+ with_document(workspace, args) do |content|
27
+ result = Edit.replace(content, args["old_string"], args["new_string"],
28
+ replace_all: args["replace_all"] == true)
29
+ workspace.write(args["path"], result.content)
30
+ "Replaced #{result.count} occurrence(s) in #{args["path"]}"
31
+ end
32
+ rescue EditError => e
33
+ "edit_file failed: #{e.message}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def find_in_file(workspace)
8
+ Tool.define("find_in_file",
9
+ "Find text in a document. Returns line-numbered matches with context, " \
10
+ "so you can locate a region without reading the whole document.",
11
+ schema: lambda {
12
+ string :path, "Document path", required: true
13
+ string :query, "Text to find (plain substring)", required: true
14
+ integer :context, "Context lines around each match"
15
+ }) do |args|
16
+ with_document(workspace, args) do |content|
17
+ Tools.find_matches(content, args["query"], (args["context"] || 2).to_i)
18
+ end
19
+ end
20
+ end
21
+
22
+ def find_matches(content, query, context)
23
+ lines = content.lines
24
+ hits = lines.each_index.select { |i| lines[i].include?(query) }
25
+ return "No matches for #{query.inspect}." if hits.empty?
26
+
27
+ blocks = hits.first(20).map do |hit|
28
+ from = [hit - context, 0].max
29
+ to = [hit + context, lines.length - 1].min
30
+ (from..to).map { |n| "#{n + 1}: #{lines[n]}" }.join
31
+ end
32
+ notice = hits.length > 20 ? "\n[#{hits.length - 20} more matches not shown]" : ""
33
+ "#{blocks.join("---\n")}#{notice}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def list_files(workspace)
8
+ Tool.define("list_files",
9
+ "List document paths in the workspace, optionally under a prefix.",
10
+ schema: -> { string :prefix, "Only paths starting with this" }) do |args|
11
+ paths = workspace.list(args["prefix"])
12
+ paths.empty? ? "No documents found." : paths.join("\n")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ MAX_READ_CHARS = 20_000
6
+
7
+ module_function
8
+
9
+ def read_file(workspace)
10
+ Tool.define("read_file",
11
+ "Read a document with line numbers. Use offset and limit for a window " \
12
+ "into a long document.",
13
+ schema: lambda {
14
+ string :path, "Document path", required: true
15
+ integer :offset, "First line to read (1-based)"
16
+ integer :limit, "How many lines to read"
17
+ }) do |args|
18
+ with_document(workspace, args) do |content|
19
+ Tools.numbered_window(content, args["offset"], args["limit"])
20
+ end
21
+ end
22
+ end
23
+
24
+ def numbered_window(content, offset, limit)
25
+ lines = content.lines
26
+ from = [(offset || 1).to_i, 1].max
27
+ to = limit ? [from + limit.to_i - 1, lines.length].min : lines.length
28
+ numbered = (from..to).map { |n| "#{n}: #{lines[n - 1]}" }.join
29
+ windowed = to < lines.length || from > 1
30
+ suffix = windowed ? "\n[showing lines #{from}-#{to} of #{lines.length}]" : ""
31
+ return "#{numbered}#{suffix}" if numbered.length <= MAX_READ_CHARS
32
+
33
+ cut = numbered[0, MAX_READ_CHARS]
34
+ cut = cut[0..(cut.rindex("\n") || -1)]
35
+ "#{cut}\n[truncated at #{MAX_READ_CHARS} chars; use offset/limit to read more]"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def read_memory(memory)
8
+ Tool.define("read_memory",
9
+ "Read the durable memory: knowledge kept across sessions. Check it " \
10
+ "before starting work that earlier sessions may have learned about.") do |_args|
11
+ content = memory.read
12
+ content.empty? ? "Memory is empty." : content
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ # Whole-document replace on purpose: the model rewrites memory as one
8
+ # coherent text instead of appending fragments that drift.
9
+ def update_memory(memory)
10
+ Tool.define("update_memory",
11
+ "Replace the durable memory with an updated version. Pass the FULL " \
12
+ "text: what you were given plus what you learned, rewritten to stay " \
13
+ "short and current.",
14
+ schema: lambda {
15
+ string :content, "The complete new memory text", required: true
16
+ }) do |args|
17
+ memory.replace(args["content"])
18
+ "Memory updated (#{args["content"].to_s.length} chars)."
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def write_file(workspace)
8
+ Tool.define("write_file",
9
+ "Create or fully overwrite a document with the given content.",
10
+ eager_input_streaming: true,
11
+ schema: lambda {
12
+ string :path, "Document path", required: true
13
+ string :content, "The full document content", required: true
14
+ }) do |args|
15
+ workspace.write(args["path"], args["content"])
16
+ "Wrote #{args["path"]} (#{args["content"].to_s.length} chars)"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -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,187 @@
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
+ def close
49
+ @mutex.synchronize { teardown }
50
+ end
51
+
52
+ private
53
+
54
+ def stream_locked(path, body, headers, signal, &block)
55
+ retried = false
56
+ begin
57
+ started = false
58
+ aborted = false
59
+ conn = connection
60
+ # The closer targets this turn's connection, so an abort that fires
61
+ # late, after the turn already finished, cannot touch a successor's
62
+ # socket.
63
+ closer = signal&.on_abort { quietly_finish(conn) }
64
+ begin
65
+ conn.request(build_request(path, body, headers, streaming: true)) do |response|
66
+ started = true
67
+ raise_for_status(response)
68
+ aborted = read_stream(response, signal, &block)
69
+ end
70
+ ensure
71
+ signal&.remove_callback(closer) if closer
72
+ end
73
+ # An abort mid-body leaves unread bytes on the socket; drop it rather
74
+ # than let the next request read stale frames.
75
+ teardown if aborted
76
+ aborted ? :aborted : nil
77
+ rescue IOError, SocketError, SystemCallError, Timeout::Error => e
78
+ teardown
79
+ return :aborted if signal&.aborted?
80
+
81
+ # A dead idle keep-alive socket fails before the response starts and
82
+ # retries safely. A drop after events flowed must not replay the turn.
83
+ if !started && !retried
84
+ retried = true
85
+ retry
86
+ end
87
+ raise ProviderError, "connection lost mid-stream: #{e.message}"
88
+ end
89
+ end
90
+
91
+ def read_stream(response, signal, &block)
92
+ sse = SSE.new
93
+ aborted = false
94
+ response.read_body do |fragment|
95
+ if signal&.aborted?
96
+ aborted = true
97
+ break
98
+ end
99
+ sse.feed(fragment, &block)
100
+ end
101
+ sse.finish(&block) unless aborted
102
+ aborted
103
+ end
104
+
105
+ def build_request(path, body, headers, streaming: false)
106
+ request = Net::HTTP::Post.new(URI("#{@origin}#{path}"))
107
+ request["Content-Type"] = "application/json"
108
+ if streaming
109
+ request["Accept"] = "text/event-stream"
110
+ # Net::HTTP silently negotiates gzip, and its inflater buffers the
111
+ # whole stream, delivering "live" events in one burst at the end.
112
+ request["Accept-Encoding"] = "identity"
113
+ end
114
+ headers.each { |key, value| request[key] = value }
115
+ request.body = JSON.generate(body)
116
+ request
117
+ end
118
+
119
+ def with_retry
120
+ attempted = false
121
+ begin
122
+ yield
123
+ rescue Timeout::Error => e
124
+ # The server may already be executing the request; a replay risks
125
+ # running it twice.
126
+ teardown
127
+ raise ProviderError, "request timed out: #{e.message}"
128
+ rescue IOError, SocketError, SystemCallError => e
129
+ teardown
130
+ raise ProviderError, "connection failed: #{e.message}" if attempted
131
+
132
+ attempted = true
133
+ retry
134
+ end
135
+ end
136
+
137
+ def connection
138
+ @connection ||= Net::HTTP.new(@uri.host, @uri.port).tap do |http|
139
+ http.use_ssl = @uri.scheme == "https"
140
+ http.open_timeout = @open_timeout
141
+ http.read_timeout = @read_timeout
142
+ http.write_timeout = @write_timeout
143
+ http.keep_alive_timeout = KEEP_ALIVE_SECONDS
144
+ http.start
145
+ end
146
+ end
147
+
148
+ def teardown
149
+ @connection&.finish
150
+ rescue IOError
151
+ nil
152
+ ensure
153
+ @connection = nil
154
+ end
155
+
156
+ def quietly_finish(conn)
157
+ conn.finish
158
+ rescue IOError
159
+ nil
160
+ end
161
+
162
+ def raise_for_status(response)
163
+ status = response.code.to_i
164
+ return if (200..299).cover?(status)
165
+
166
+ klass = error_class(status)
167
+ options = { status: status, body: response.read_body.to_s[0, 500] }
168
+ options[:retry_after] = retry_after(response) if klass == RateLimitError
169
+ raise klass.new(**options)
170
+ end
171
+
172
+ def error_class(status)
173
+ case status
174
+ when 401, 403 then AuthenticationError
175
+ when 429 then RateLimitError
176
+ when 529 then OverloadedError
177
+ when 500..599 then ServerError
178
+ else ProviderError
179
+ end
180
+ end
181
+
182
+ def retry_after(response)
183
+ value = response["retry-after"]
184
+ value&.match?(/\A\d+(\.\d+)?\z/) ? value.to_f : nil
185
+ end
186
+ end
187
+ 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,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Mistri
2
- VERSION = "0.0.2".freeze
4
+ VERSION = "0.1.0"
3
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