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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Mistri
8
+ # The durable record of a run: an append-only log of typed entries over a
9
+ # pluggable store. Messages replay into provider-ready history; other entry
10
+ # types (reminders, custom host data) ride alongside without the loop
11
+ # knowing their shapes.
12
+ #
13
+ # A store implements two methods: append(id, entry_hash) and load(id) ->
14
+ # array of entry hashes in append order. Everything else lives here.
15
+ class Session
16
+ attr_reader :id, :store
17
+
18
+ def initialize(store:, id: nil)
19
+ @store = store
20
+ @id = id || SecureRandom.uuid
21
+ end
22
+
23
+ def append_message(message)
24
+ append("message", "message" => message.to_h)
25
+ message
26
+ end
27
+
28
+ # Entries are normalized through JSON at write time, so every store holds
29
+ # the same canonical shape (string keys, JSON values) and reads behave
30
+ # identically whether the entry round-tripped a database or stayed in
31
+ # memory.
32
+ def append(type, data = {})
33
+ entry = { "type" => type, "at" => Time.now.utc.iso8601 }.merge(data)
34
+ @store.append(@id, JSON.parse(JSON.generate(entry)))
35
+ nil
36
+ end
37
+
38
+ def entries = @store.load(@id)
39
+
40
+ # The conversation as the model replays it.
41
+ def messages = replay.map(&:first)
42
+
43
+ # Replay messages paired with the entry index each came from, starting at
44
+ # the latest compaction boundary. The synthetic summary message carries a
45
+ # nil index. Compaction places its cuts by these indexes; the full entry
46
+ # log stays in the store for transcript views.
47
+ def replay
48
+ compaction = last_compaction
49
+ from = compaction ? compaction["kept_from"] : 0
50
+ pairs = entries.each_with_index.filter_map do |entry, index|
51
+ [Message.from_h(entry["message"]), index] if index >= from && entry["type"] == "message"
52
+ end
53
+ compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
54
+ end
55
+
56
+ def last_compaction
57
+ entries.reverse_each.find { |entry| entry["type"] == "compaction" }
58
+ end
59
+
60
+ # Queue a message for a running exchange from any process. The loop folds
61
+ # pending steers into the transcript at the next turn boundary, so the
62
+ # model sees them mid-run; one that arrives as the model finishes cleanly
63
+ # extends the run another turn so it is answered, not left dangling.
64
+ def steer(text)
65
+ append("steer", "id" => SecureRandom.uuid, "message" => Message.user(text).to_h)
66
+ end
67
+
68
+ # Steers not yet folded into the transcript, oldest first. The folding
69
+ # message entry carries the steer id, so consumption is derived from the
70
+ # log alone and reads the same from every process.
71
+ def pending_steers
72
+ folded = entries.filter_map { |entry| entry["steer_id"] }
73
+ entries.select { |entry| entry["type"] == "steer" && !folded.include?(entry["id"]) }
74
+ end
75
+
76
+ # Record a human's decision on a parked tool call. Decisions are session
77
+ # entries, so they can be written from any process, days later, with no
78
+ # agent constructed; the next resume settles them.
79
+ def approve(call_id, note: nil) = decide(call_id, approved: true, note: note)
80
+
81
+ def deny(call_id, note: nil) = decide(call_id, approved: false, note: note)
82
+
83
+ # Parked tool calls not yet settled by a tool result, each with its
84
+ # decision when one has been recorded. Derived from the entry log alone,
85
+ # so it survives crashes and reads the same from every process.
86
+ def open_approvals
87
+ answered = []
88
+ decisions = {}
89
+ requests = []
90
+ entries.each do |entry|
91
+ case entry["type"]
92
+ when "message"
93
+ call_id = entry.dig("message", "tool_call_id")
94
+ answered << call_id if call_id
95
+ when "approval_request" then requests << entry["call"]
96
+ when "approval_decision" then decisions[entry["call_id"]] = entry
97
+ end
98
+ end
99
+ requests.reject { |call| answered.include?(call["id"]) }
100
+ .map { |call| { call: rebuild_call(call), decision: decisions[call["id"]] } }
101
+ end
102
+
103
+ private
104
+
105
+ def summary_message(summary)
106
+ Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
107
+ end
108
+
109
+ def decide(call_id, approved:, note:)
110
+ unless open_approvals.any? { |open| open[:call].id == call_id }
111
+ raise ConfigurationError, "no open approval for #{call_id.inspect}"
112
+ end
113
+
114
+ entry = { "call_id" => call_id, "approved" => approved }
115
+ entry["note"] = note if note
116
+ append("approval_decision", entry)
117
+ end
118
+
119
+ def rebuild_call(hash)
120
+ ToolCall.new(id: hash["id"], name: hash["name"],
121
+ arguments: hash["arguments"] || {}, signature: hash["signature"])
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Sinks
5
+ # Broadcasts every event to an Action Cable stream as its to_h shape.
6
+ # The server resolves lazily at first call, so this file loads without
7
+ # Rails; pass server: explicitly to use another broadcaster.
8
+ #
9
+ # sink = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
10
+ # agent.run(input, &sink)
11
+ class ActionCable
12
+ def initialize(stream, server: nil)
13
+ @stream = stream
14
+ @server = server
15
+ end
16
+
17
+ def call(event)
18
+ server.broadcast(@stream, event.to_h)
19
+ end
20
+
21
+ def to_proc = method(:call).to_proc
22
+
23
+ private
24
+
25
+ def server
26
+ @server ||= ::ActionCable.server
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Sinks
5
+ # Wraps any sink and merges bursts of streaming deltas, so a transport
6
+ # broadcasts at UI speed instead of token speed. Deltas buffer per
7
+ # content block and flush merged when the interval elapses or any other
8
+ # event arrives, so ordering is preserved and a turn always ends flushed.
9
+ #
10
+ # sink = Mistri::Sinks::Coalesced.new(
11
+ # Mistri::Sinks::ActionCable.new("agent_1"), interval: 0.1,
12
+ # )
13
+ class Coalesced
14
+ DELTAS = %i[text_delta thinking_delta toolcall_delta].freeze
15
+
16
+ def initialize(sink, interval: 0.05)
17
+ @sink = sink
18
+ @interval = interval
19
+ @buffer = nil
20
+ @flushed_at = now
21
+ end
22
+
23
+ def call(event)
24
+ unless DELTAS.include?(event.type)
25
+ flush
26
+ return @sink.call(event)
27
+ end
28
+
29
+ merge(event)
30
+ flush if now - @flushed_at >= @interval
31
+ end
32
+
33
+ def to_proc = method(:call).to_proc
34
+
35
+ private
36
+
37
+ # Merged deltas keep the newest partial, so a consumer that renders
38
+ # snapshots always renders the latest one.
39
+ def merge(event)
40
+ same = @buffer && @buffer.type == event.type &&
41
+ @buffer.content_index == event.content_index
42
+ flush if @buffer && !same
43
+ @buffer = if same
44
+ @buffer.with(delta: @buffer.delta.to_s + event.delta.to_s,
45
+ partial: event.partial)
46
+ else
47
+ event
48
+ end
49
+ end
50
+
51
+ def flush
52
+ pending = @buffer
53
+ @buffer = nil
54
+ @flushed_at = now
55
+ @sink.call(pending) if pending
56
+ end
57
+
58
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ module Sinks
7
+ # Writes events as Server-Sent Events to any IO-like object: a Rack
8
+ # streaming body, an ActionController::Live stream, a socket. Pure
9
+ # formatting, the outbound counterpart of the Mistri::SSE decoder.
10
+ #
11
+ # response.headers["Content-Type"] = "text/event-stream"
12
+ # agent.run(input, &Mistri::Sinks::SSE.new(response.stream))
13
+ class SSE
14
+ def initialize(io)
15
+ @io = io
16
+ end
17
+
18
+ def call(event)
19
+ @io.write("event: #{event.type}\ndata: #{JSON.generate(event.to_h)}\n\n")
20
+ @io.flush if @io.respond_to?(:flush)
21
+ end
22
+
23
+ def to_proc = method(:call).to_proc
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # One expert playbook: a name the model selects by, a description that
5
+ # earns the selection, and the full body it reads before acting. Build
6
+ # them from anywhere — Skills.load reads a directory, and a host with
7
+ # skills in a database constructs these directly.
8
+ Skill = Data.define(:name, :description, :body) do
9
+ def initialize(name:, description: "", body: "")
10
+ raise ConfigurationError, "a skill needs a name" if name.to_s.empty?
11
+
12
+ super(name: name.to_s, description: description.to_s, body: body.to_s)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Loads skills and wires them into an agent: their one-line descriptions
5
+ # ride the system prompt, and the model pulls a full body on demand with
6
+ # the read_skill tool — so a large library costs almost nothing until a
7
+ # skill is actually used.
8
+ module Skills
9
+ module_function
10
+
11
+ # Reads a directory of skills in either layout: <dir>/<name>/SKILL.md
12
+ # or <dir>/<name>.md. Frontmatter (name:, description:) overrides the
13
+ # path-derived name.
14
+ def load(path)
15
+ raise ConfigurationError, "no skills directory at #{path}" unless File.directory?(path)
16
+
17
+ skills = Dir.children(path).sort.filter_map do |child|
18
+ full = File.join(path, child)
19
+ if File.directory?(full) && File.file?(File.join(full, "SKILL.md"))
20
+ read(File.join(full, "SKILL.md"), default_name: child)
21
+ elsif child.end_with?(".md") && File.file?(full)
22
+ read(full, default_name: File.basename(child, ".md"))
23
+ end
24
+ end
25
+ skills.sort_by(&:name)
26
+ end
27
+
28
+ def read(file, default_name:)
29
+ meta, body = parse(File.read(file))
30
+ Skill.new(name: meta.fetch("name", default_name),
31
+ description: meta.fetch("description", ""), body: body)
32
+ end
33
+
34
+ # The always-present cost of a skill library: one line per skill.
35
+ def section(skills)
36
+ lines = skills.map { |skill| "- #{skill.name}: #{skill.description}" }
37
+ <<~TEXT.strip
38
+ ## Skills
39
+
40
+ Expert playbooks. Before acting, check this list: when a skill
41
+ matches the task, you MUST call read_skill with its name and follow
42
+ the playbook.
43
+
44
+ #{lines.join("\n")}
45
+ TEXT
46
+ end
47
+
48
+ def amend(system, skills)
49
+ return system if skills.empty?
50
+
51
+ [system, section(skills)].compact.join("\n\n")
52
+ end
53
+
54
+ def reader(skills)
55
+ by_name = skills.to_h { |skill| [skill.name, skill] }
56
+ Tool.define("read_skill", "Reads the full playbook for a named skill.",
57
+ schema: -> { string :name, "Skill name", required: true }) do |args|
58
+ skill = by_name[args["name"]]
59
+ next skill.body if skill
60
+
61
+ "Unknown skill #{args["name"].inspect}. Available: #{by_name.keys.join(", ")}"
62
+ end
63
+ end
64
+
65
+ # Frontmatter is deliberately a subset: flat string keys between ---
66
+ # markers, quotes optional. name and description are the whole contract,
67
+ # and a YAML dependency is not worth two fields.
68
+ def parse(text)
69
+ return [{}, text] unless text.start_with?("---\n")
70
+
71
+ head, separator, body = text[4..].partition("\n---\n")
72
+ return [{}, text] if separator.empty?
73
+
74
+ meta = {}
75
+ head.scan(/^([a-z_]+):[ \t]*(.+?)[ \t]*$/) do |key, value|
76
+ meta[key] = value.gsub(/\A["']|["']\z/, "")
77
+ end
78
+ [meta, body.sub(/\A\n+/, "")]
79
+ end
80
+ end
81
+ end
data/lib/mistri/sse.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # An incremental Server-Sent Events decoder. Feed it raw socket fragments in
7
+ # any chunking; it buffers partial lines across fragments and yields each
8
+ # complete "data:" record as a parsed Hash.
9
+ #
10
+ # The decode is deliberately tolerant of what the provider APIs actually
11
+ # send: one single-line JSON object per "data:" record. "event:", "id:",
12
+ # comment, and blank lines are ignored (the event name is duplicated inside
13
+ # the data payload), OpenAI's "[DONE]" sentinel is dropped, and a record that
14
+ # fails to parse is skipped rather than killing a live stream.
15
+ class SSE
16
+ def initialize
17
+ @buffer = +""
18
+ end
19
+
20
+ def feed(fragment, &)
21
+ @buffer << fragment
22
+ while (newline = @buffer.index("\n"))
23
+ line = @buffer.slice!(0, newline + 1)
24
+ decode(line.chomp, &)
25
+ end
26
+ nil
27
+ end
28
+
29
+ # Flush a trailing record that arrived without a final newline.
30
+ def finish(&)
31
+ decode(@buffer.chomp, &) unless @buffer.empty?
32
+ @buffer.clear
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ def decode(line)
39
+ return unless line.start_with?("data:")
40
+
41
+ data = line.delete_prefix("data:").strip
42
+ return if data.empty? || data == "[DONE]"
43
+
44
+ decoded = JSON.parse(data)
45
+ yield decoded if decoded.is_a?(Hash)
46
+ rescue JSON::ParserError
47
+ nil
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Why an assistant turn stopped. Providers map their native finish reasons
5
+ # onto this one set, so the loop and the host never read wire spellings.
6
+ #
7
+ # :stop - the model finished its answer
8
+ # :length - it hit the max-tokens ceiling mid-turn
9
+ # :tool_use - it paused to call one or more tools
10
+ # :error - the provider or runtime failed
11
+ # :aborted - the host cancelled the turn
12
+ # :budget - a configured ceiling stopped the run
13
+ module StopReason
14
+ STOP = :stop
15
+ LENGTH = :length
16
+ TOOL_USE = :tool_use
17
+ ERROR = :error
18
+ ABORTED = :aborted
19
+ BUDGET = :budget
20
+
21
+ ALL = [STOP, LENGTH, TOOL_USE, ERROR, ABORTED, BUDGET].freeze
22
+
23
+ def self.valid?(reason) = ALL.include?(reason)
24
+ end
25
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ module Stores
7
+ # Entries in the host's own database, through a model class the host
8
+ # supplies, so sessions live where application data lives: MySQL,
9
+ # Postgres, whatever the app runs. Not auto-required; load it with
10
+ # require "mistri/stores/active_record".
11
+ #
12
+ # The model needs three columns: session_id (string, indexed), position
13
+ # (integer), payload (text). A migration to copy:
14
+ #
15
+ # create_table :mistri_entries do |t|
16
+ # t.string :session_id, null: false, index: true
17
+ # t.integer :position, null: false
18
+ # t.text :payload, size: :medium, null: false
19
+ # t.timestamps
20
+ # end
21
+ # add_index :mistri_entries, [:session_id, :position], unique: true
22
+ #
23
+ # The unique index is load-bearing: one session has one writer at a time
24
+ # (the loop is serial), and if a host ever runs two agents on one session,
25
+ # a colliding append raises instead of silently corrupting entry order.
26
+ #
27
+ # Reads select only (position, payload) and sort in Ruby: ORDER BY over
28
+ # rows carrying multi-megabyte payloads exhausts MySQL's sort buffer.
29
+ class ActiveRecord
30
+ def initialize(model)
31
+ @model = model
32
+ end
33
+
34
+ def append(id, entry)
35
+ position = @model.where(session_id: id).maximum(:position).to_i + 1
36
+ @model.create!(session_id: id, position: position, payload: JSON.generate(entry))
37
+ nil
38
+ end
39
+
40
+ def load(id)
41
+ @model.where(session_id: id).pluck(:position, :payload)
42
+ .sort_by(&:first)
43
+ .map { |_position, payload| JSON.parse(payload) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Mistri
7
+ module Stores
8
+ # One JSONL file per session under a directory: durable sessions without
9
+ # a database, and a transcript any tool can read line by line.
10
+ class JSONL
11
+ def initialize(dir)
12
+ @dir = dir
13
+ FileUtils.mkdir_p(dir)
14
+ end
15
+
16
+ def append(id, entry)
17
+ File.open(path(id), "a") { |file| file.puts(JSON.generate(entry)) }
18
+ nil
19
+ end
20
+
21
+ def load(id)
22
+ return [] unless File.exist?(path(id))
23
+
24
+ File.readlines(path(id)).filter_map do |line|
25
+ JSON.parse(line) unless line.strip.empty?
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # Session ids become filenames; anything path-hostile is replaced.
32
+ def path(id)
33
+ File.join(@dir, "#{id.to_s.gsub(/[^a-zA-Z0-9_-]/, "-")}.jsonl")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Stores
5
+ # Entries in a plain Hash: for tests and runs that need no persistence.
6
+ class Memory
7
+ def initialize
8
+ @entries = Hash.new { |hash, key| hash[key] = [] }
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def append(id, entry)
13
+ @mutex.synchronize { @entries[id] << entry }
14
+ nil
15
+ end
16
+
17
+ def load(id)
18
+ @mutex.synchronize { @entries[id].dup }
19
+ end
20
+ end
21
+ end
22
+ end