rixie 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 (87) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/bin/rixie +7 -0
  6. data/lib/rixie/agent/compressor.rb +41 -0
  7. data/lib/rixie/agent/plan.rb +62 -0
  8. data/lib/rixie/agent/re_act.rb +53 -0
  9. data/lib/rixie/agent.rb +122 -0
  10. data/lib/rixie/cli/commands/base.rb +33 -0
  11. data/lib/rixie/cli/commands/compress.rb +49 -0
  12. data/lib/rixie/cli/commands/context.rb +18 -0
  13. data/lib/rixie/cli/commands/help.rb +21 -0
  14. data/lib/rixie/cli/commands/model.rb +25 -0
  15. data/lib/rixie/cli/commands/strategy.rb +50 -0
  16. data/lib/rixie/cli/commands.rb +8 -0
  17. data/lib/rixie/cli/markdown.rb +59 -0
  18. data/lib/rixie/cli/renderer.rb +171 -0
  19. data/lib/rixie/cli/spinner.rb +47 -0
  20. data/lib/rixie/cli/terminal.rb +28 -0
  21. data/lib/rixie/cli.rb +285 -0
  22. data/lib/rixie/configuration.rb +56 -0
  23. data/lib/rixie/context/history.rb +62 -0
  24. data/lib/rixie/context/plan.rb +31 -0
  25. data/lib/rixie/context/summary.rb +25 -0
  26. data/lib/rixie/error.rb +34 -0
  27. data/lib/rixie/event/compression_end.rb +7 -0
  28. data/lib/rixie/event/compression_start.rb +7 -0
  29. data/lib/rixie/event/envelope.rb +7 -0
  30. data/lib/rixie/event/finished.rb +7 -0
  31. data/lib/rixie/event/llm_call_start.rb +7 -0
  32. data/lib/rixie/event/run_end.rb +7 -0
  33. data/lib/rixie/event/run_start.rb +7 -0
  34. data/lib/rixie/event/task_end.rb +7 -0
  35. data/lib/rixie/event/task_start.rb +7 -0
  36. data/lib/rixie/event/thought_completed.rb +7 -0
  37. data/lib/rixie/event/token.rb +7 -0
  38. data/lib/rixie/event/tool_call_end.rb +7 -0
  39. data/lib/rixie/event/tool_call_start.rb +7 -0
  40. data/lib/rixie/event/tool_calls_completed.rb +7 -0
  41. data/lib/rixie/event.rb +16 -0
  42. data/lib/rixie/event_listener.rb +36 -0
  43. data/lib/rixie/http/client.rb +140 -0
  44. data/lib/rixie/llm/adapter/dummy.rb +38 -0
  45. data/lib/rixie/llm/adapter/openai.rb +147 -0
  46. data/lib/rixie/llm/client/resolver.rb +58 -0
  47. data/lib/rixie/llm/client.rb +33 -0
  48. data/lib/rixie/llm/response.rb +19 -0
  49. data/lib/rixie/llm/tool_call.rb +36 -0
  50. data/lib/rixie/mcp/http/client.rb +86 -0
  51. data/lib/rixie/mcp/http.rb +3 -0
  52. data/lib/rixie/mcp.rb +3 -0
  53. data/lib/rixie/message.rb +10 -0
  54. data/lib/rixie/prompt_builder.rb +13 -0
  55. data/lib/rixie/run.rb +60 -0
  56. data/lib/rixie/search/base.rb +13 -0
  57. data/lib/rixie/search/duck_duck_go.rb +66 -0
  58. data/lib/rixie/search/wikipedia.rb +59 -0
  59. data/lib/rixie/session.rb +153 -0
  60. data/lib/rixie/store/base.rb +37 -0
  61. data/lib/rixie/store/memory.rb +30 -0
  62. data/lib/rixie/store/null.rb +19 -0
  63. data/lib/rixie/strategy/plan_execute.rb +65 -0
  64. data/lib/rixie/strategy/re_act.rb +15 -0
  65. data/lib/rixie/strategy/simple.rb +14 -0
  66. data/lib/rixie/subscriber.rb +12 -0
  67. data/lib/rixie/subscribers/event_severity.rb +23 -0
  68. data/lib/rixie/subscribers/json_logger.rb +70 -0
  69. data/lib/rixie/subscribers/logger.rb +65 -0
  70. data/lib/rixie/task.rb +53 -0
  71. data/lib/rixie/token_counter.rb +10 -0
  72. data/lib/rixie/tool/calculator.rb +154 -0
  73. data/lib/rixie/tool/current_time.rb +30 -0
  74. data/lib/rixie/tool/fetch.rb +42 -0
  75. data/lib/rixie/tool/file_list.rb +39 -0
  76. data/lib/rixie/tool/file_read.rb +53 -0
  77. data/lib/rixie/tool/file_sandbox.rb +33 -0
  78. data/lib/rixie/tool/file_search.rb +72 -0
  79. data/lib/rixie/tool/human_input.rb +24 -0
  80. data/lib/rixie/tool/web_search.rb +34 -0
  81. data/lib/rixie/tool/wikipedia_search.rb +38 -0
  82. data/lib/rixie/tool.rb +23 -0
  83. data/lib/rixie/tool_executor.rb +34 -0
  84. data/lib/rixie/version.rb +5 -0
  85. data/lib/rixie.rb +74 -0
  86. data/sig/rixie.rbs +4 -0
  87. metadata +146 -0
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client/resolver"
4
+
5
+ module Rixie
6
+ module LLM
7
+ class Client
8
+ attr_reader :model, :provider
9
+
10
+ def initialize(model: nil, provider: nil, adapter: nil, stream: false, request_timeout: nil, max_tokens: nil, temperature: nil, parallel_tool_calls: nil)
11
+ @model = model
12
+ @provider = provider
13
+ @stream = stream
14
+ @adapter = adapter || Client::Resolver.resolve(
15
+ model: model,
16
+ provider: provider,
17
+ request_timeout: request_timeout,
18
+ max_tokens: max_tokens,
19
+ temperature: temperature,
20
+ parallel_tool_calls: parallel_tool_calls
21
+ )
22
+ end
23
+
24
+ def call(messages, tools:, &block)
25
+ if @stream
26
+ @adapter.stream(messages, tools: tools, &block)
27
+ else
28
+ @adapter.chat(messages, tools: tools)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module LLM
5
+ Response = Data.define(:content, :tool_calls, :finish_reason) do
6
+ def self.from_openai_wire(raw)
7
+ choices = raw["choices"] || []
8
+ choice = choices.first || {}
9
+ message = choice["message"] || {}
10
+ tool_calls = (message["tool_calls"] || []).map { |tc| LLM::ToolCall.from_openai_wire(tc) }
11
+ new(content: message["content"], tool_calls: tool_calls, finish_reason: choice["finish_reason"])
12
+ end
13
+
14
+ def has_tool_calls?
15
+ tool_calls.any?
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rixie
6
+ module LLM
7
+ class ToolCall
8
+ attr_reader :id, :name, :arguments
9
+
10
+ def initialize(id:, name:, arguments:)
11
+ @id = id
12
+ @name = name
13
+ @arguments = arguments
14
+ end
15
+
16
+ def self.from_openai_wire(raw)
17
+ new(
18
+ id: raw["id"],
19
+ name: raw["function"]["name"],
20
+ arguments: JSON.parse(raw["function"]["arguments"])
21
+ )
22
+ end
23
+
24
+ def to_openai_wire
25
+ {
26
+ "id" => @id,
27
+ "type" => "function",
28
+ "function" => {
29
+ "name" => @name,
30
+ "arguments" => JSON.generate(@arguments)
31
+ }
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Rixie
7
+ module MCP
8
+ module Http
9
+ class Client
10
+ MCP_PROTOCOL_VERSION = "2024-11-05"
11
+
12
+ def initialize(url:, headers: {}, client_info: nil, http_client: nil)
13
+ uri = URI.parse(url)
14
+ @url = url
15
+ @path = uri.path.empty? ? "/" : uri.path
16
+ @http_client = Rixie::Http::Client.new(
17
+ headers: {
18
+ "Content-Type" => "application/json",
19
+ "Accept" => "application/json, text/event-stream"
20
+ }.merge(headers),
21
+ http_client: http_client,
22
+ allow_private: true
23
+ )
24
+ @client_info = client_info || {name: "rixie", version: Rixie::VERSION}
25
+ @request_id = 0
26
+ @session_initialized = false
27
+ end
28
+
29
+ def tools
30
+ list_tools.map do |tool_def|
31
+ name = tool_def["name"]
32
+ Rixie::Tool.new(
33
+ name: name,
34
+ description: tool_def["description"],
35
+ input_schema: tool_def["inputSchema"],
36
+ call: ->(args) { call_tool(name, args) }
37
+ )
38
+ end
39
+ end
40
+
41
+ def list_tools
42
+ initialize_session
43
+ result = request("tools/list", {})
44
+ result.dig("result", "tools") || []
45
+ end
46
+
47
+ def call_tool(name, arguments = {})
48
+ initialize_session
49
+ result = request("tools/call", {name: name, arguments: arguments})
50
+ result.dig("result", "content").map { |c| c["text"] }.join
51
+ end
52
+
53
+ private
54
+
55
+ def initialize_session
56
+ return if @session_initialized
57
+
58
+ request("initialize", {
59
+ protocolVersion: MCP_PROTOCOL_VERSION,
60
+ capabilities: {},
61
+ clientInfo: @client_info
62
+ })
63
+ @session_initialized = true
64
+ end
65
+
66
+ def request(method, params)
67
+ @request_id += 1
68
+ body = JSON.generate({jsonrpc: "2.0", id: @request_id, method: method, params: params})
69
+
70
+ response = @http_client.post(@url, body: body)
71
+ parsed = JSON.parse(response[:body])
72
+
73
+ if parsed["error"]
74
+ raise Rixie::MCP::ProtocolError, parsed.dig("error", "message")
75
+ end
76
+
77
+ parsed
78
+ rescue Rixie::Http::TimeoutError => e
79
+ raise Rixie::MCP::TimeoutError, e.message
80
+ rescue Rixie::Http::Error => e
81
+ raise Rixie::MCP::RequestError, e.message
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rixie/mcp/http/client"
data/lib/rixie/mcp.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rixie/mcp/http"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Message
5
+ System = Data.define(:content)
6
+ User = Data.define(:content)
7
+ Assistant = Data.define(:content, :tool_calls)
8
+ Tool = Data.define(:tool_call_id, :content)
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ class PromptBuilder
5
+ def build(user_input:, instructions:, context:)
6
+ messages = []
7
+ messages << Message::System.new(content: instructions)
8
+ messages.concat(context.flat_map(&:to_message))
9
+ messages << Message::User.new(content: user_input)
10
+ messages
11
+ end
12
+ end
13
+ end
data/lib/rixie/run.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Rixie
6
+ class Run
7
+ attr_reader :id, :user_input, :agent, :context, :thoughts, :steps, :status, :output
8
+
9
+ def initialize(user_input:, agent:, context:)
10
+ @id = SecureRandom.uuid
11
+ @user_input = user_input
12
+ @agent = agent
13
+ @context = context
14
+ @thoughts = []
15
+ @steps = []
16
+ @status = "running"
17
+ @output = nil
18
+ end
19
+
20
+ def execute(listener:)
21
+ listener.run_id = @id
22
+ listener.emit(Event::RunStart.new(user_input: user_input))
23
+ messages = PromptBuilder.new.build(
24
+ user_input: user_input,
25
+ instructions: agent.instructions,
26
+ context: context
27
+ )
28
+
29
+ result = agent.think(messages:, listener:)
30
+ @output = result.content
31
+ @thoughts = result.thoughts
32
+ @status = "completed"
33
+ listener.emit(Event::RunEnd.new(output: @output, status: @status))
34
+ rescue
35
+ @status = "failed"
36
+ listener.emit(Event::RunEnd.new(output: nil, status: @status))
37
+ raise
38
+ end
39
+
40
+ def add_step(tool_calls:, tool_results:)
41
+ @steps << {tool_calls: tool_calls, tool_results: tool_results}
42
+ end
43
+
44
+ def completed?
45
+ @status == "completed"
46
+ end
47
+
48
+ def failed?
49
+ @status == "failed"
50
+ end
51
+
52
+ def find_tool_call(name)
53
+ thoughts.select(&:tool_call?).flat_map(&:tool_calls).find { it.name == name }
54
+ end
55
+
56
+ def to_history
57
+ Context::History.new(input: user_input, thoughts: thoughts, output: output)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Search
5
+ # Interface for search providers.
6
+ # Implementations must return Array<Hash> with keys: title, snippet, url.
7
+ class Base
8
+ def search(query, max_results:)
9
+ raise Rixie::NotImplementedError, "#{self.class}#search not implemented"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Rixie
6
+ module Search
7
+ class DuckDuckGo < Base
8
+ SEARCH_URL = "https://lite.duckduckgo.com/lite/"
9
+ DEFAULT_MAX_RESULTS = 5
10
+
11
+ def initialize(http_client: nil)
12
+ @http_client = Rixie::Http::Client.new(
13
+ headers: {
14
+ "Accept" => "text/html",
15
+ "Accept-Language" => "en-US,en;q=0.9"
16
+ },
17
+ http_client: http_client
18
+ )
19
+ end
20
+
21
+ def search(query, max_results: DEFAULT_MAX_RESULTS)
22
+ begin
23
+ require "nokogiri"
24
+ rescue LoadError
25
+ raise Rixie::ConfigurationError, "nokogiri gem is required for Search::DuckDuckGo. Add `gem 'nokogiri'` to your Gemfile."
26
+ end
27
+
28
+ url = "#{SEARCH_URL}?q=#{URI.encode_www_form_component(query)}"
29
+ response = @http_client.get(url)
30
+ doc = Nokogiri::HTML(response[:body])
31
+ parse_results(doc, max_results)
32
+ end
33
+
34
+ private
35
+
36
+ def parse_results(doc, max_results)
37
+ results = []
38
+ doc.css("a.result-link").each do |a|
39
+ url = extract_url(a["href"].to_s)
40
+ next unless url
41
+
42
+ title = a.text.strip
43
+ next if title.empty?
44
+
45
+ snippet_td = a.ancestors("tr").first&.next_element&.css("td.result-snippet")&.first
46
+ snippet = snippet_td&.text&.strip.to_s
47
+
48
+ results << {title: title, url: url, snippet: snippet}
49
+ break if results.size >= max_results
50
+ end
51
+ results
52
+ rescue
53
+ []
54
+ end
55
+
56
+ def extract_url(href)
57
+ return nil unless href.to_s.start_with?("//")
58
+
59
+ uddg = URI.decode_www_form(URI.parse("https:#{href}").query.to_s).to_h["uddg"]
60
+ uddg if uddg&.match?(/\Ahttps?:\/\//)
61
+ rescue URI::InvalidURIError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Rixie
7
+ module Search
8
+ class Wikipedia < Base
9
+ DEFAULT_MAX_RESULTS = 5
10
+ DEFAULT_LANGUAGE = "en"
11
+
12
+ def initialize(language: DEFAULT_LANGUAGE, http_client: nil)
13
+ @language = language
14
+ @http_client = Rixie::Http::Client.new(
15
+ headers: {"Accept" => "application/json"},
16
+ http_client: http_client
17
+ )
18
+ end
19
+
20
+ def search(query, max_results: DEFAULT_MAX_RESULTS)
21
+ response = @http_client.get(build_url(query, max_results))
22
+ data = JSON.parse(response[:body].to_s)
23
+ Array(data.dig("query", "search")).map { |hit| format_result(hit) }
24
+ rescue JSON::ParserError
25
+ []
26
+ end
27
+
28
+ private
29
+
30
+ def build_url(query, max_results)
31
+ params = URI.encode_www_form(
32
+ action: "query",
33
+ list: "search",
34
+ srsearch: query,
35
+ srlimit: max_results,
36
+ format: "json",
37
+ formatversion: 2
38
+ )
39
+ "https://#{@language}.wikipedia.org/w/api.php?#{params}"
40
+ end
41
+
42
+ def format_result(hit)
43
+ title = hit["title"].to_s
44
+ {
45
+ title: title,
46
+ snippet: strip_html(hit["snippet"].to_s),
47
+ url: "https://#{@language}.wikipedia.org/wiki/#{URI.encode_www_form_component(title.tr(" ", "_"))}"
48
+ }
49
+ end
50
+
51
+ ENTITIES = {"&quot;" => '"', "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&#39;" => "'", "&nbsp;" => " "}.freeze
52
+ private_constant :ENTITIES
53
+
54
+ def strip_html(text)
55
+ text.gsub(/<[^>]+>/, "").gsub(/&\w+;|&#\d+;/) { |e| ENTITIES[e] || e }.strip
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Rixie
6
+ class Session
7
+ attr_reader :agent, :tasks, :session_id, :stream_client
8
+
9
+ def initialize(agent: nil, stream_client: nil, instructions: nil, tools: [], model: nil, provider: nil, max_steps: nil, llm_client: nil, store: nil, initial_context: [], request_timeout: nil, max_tokens: nil, temperature: nil, token_counter: nil, parallel_tool_calls: false, subscribers: [])
10
+ resolved_provider = provider || Rixie.config.default_provider
11
+ resolved_model = model || Rixie.config.default_model
12
+ resolved_timeout = request_timeout || Rixie.config.request_timeout
13
+
14
+ resolved_max_tokens = max_tokens || Rixie.config.default_max_tokens
15
+ resolved_temperature = temperature || Rixie.config.default_temperature
16
+
17
+ @agent = agent || Agent.new(
18
+ instructions: instructions,
19
+ tools: tools,
20
+ max_steps: max_steps || Rixie.config.default_max_steps,
21
+ parallel_tool_calls: parallel_tool_calls,
22
+ llm_client: llm_client || LLM::Client.new(
23
+ model: resolved_model,
24
+ provider: resolved_provider,
25
+ request_timeout: resolved_timeout,
26
+ max_tokens: resolved_max_tokens,
27
+ temperature: resolved_temperature,
28
+ parallel_tool_calls: parallel_tool_calls
29
+ )
30
+ )
31
+
32
+ @stream_client = if agent.nil?
33
+ stream_client || (resolved_provider ? LLM::Client.new(
34
+ model: resolved_model,
35
+ provider: resolved_provider,
36
+ stream: true,
37
+ request_timeout: resolved_timeout,
38
+ max_tokens: resolved_max_tokens,
39
+ temperature: resolved_temperature,
40
+ parallel_tool_calls: parallel_tool_calls
41
+ ) : nil)
42
+ else
43
+ stream_client
44
+ end
45
+
46
+ default_subs = Rixie.config.default_subscribers || [default_log_subscriber]
47
+ @subscribers = default_subs + subscribers
48
+ @store = store || Rixie.config.store || Store::Memory.new
49
+ @token_counter = token_counter || TokenCounter::DEFAULT
50
+ @initial_context = initial_context
51
+ @session_id = SecureRandom.uuid
52
+ @tasks = []
53
+ @summary = nil
54
+ end
55
+
56
+ def chat(user_input, strategy: Strategy::Simple.new)
57
+ task = Task.new(user_input: user_input, agent: agent, context: context, strategy: strategy, subscribers: @subscribers, session_id: @session_id)
58
+ task.execute
59
+ @tasks << task
60
+ @store.save(@session_id, context)
61
+ task.output
62
+ end
63
+
64
+ def live(user_input, strategy: Strategy::Simple.new)
65
+ Enumerator.new do |yielder|
66
+ stream_agent = @agent.with_llm_client(@stream_client)
67
+ stream_sub = build_stream_subscriber(yielder)
68
+
69
+ task = Task.new(
70
+ user_input: user_input,
71
+ agent: stream_agent,
72
+ context: context,
73
+ strategy: strategy,
74
+ subscribers: @subscribers + [stream_sub],
75
+ session_id: @session_id
76
+ )
77
+ task.execute
78
+ @tasks << task
79
+ @store.save(@session_id, context)
80
+ end
81
+ end
82
+
83
+ def compress!(keep_recent: 0, compressor: nil)
84
+ return if context.empty?
85
+
86
+ recent = context.last(keep_recent)
87
+ to_compress = context.first(context.size - recent.size)
88
+
89
+ return if to_compress.empty?
90
+
91
+ summary_input = to_compress.flat_map(&:to_message).map { |msg|
92
+ case msg
93
+ when Message::System then "[system] #{msg.content}"
94
+ when Message::User then "[user] #{msg.content}"
95
+ when Message::Assistant then "[assistant] #{msg.content}"
96
+ when Message::Tool then "[tool_result id=#{msg.tool_call_id}] #{msg.content}"
97
+ end
98
+ }.join("\n\n")
99
+
100
+ listener = EventListener.new(session_id: @session_id)
101
+ @subscribers.each { |s| s.subscribe(listener) }
102
+ listener.emit(Event::CompressionStart.new(entry_count: to_compress.size, keep_recent: keep_recent))
103
+
104
+ task = Task.new(
105
+ user_input: summary_input,
106
+ agent: compressor || Agent::Compressor.new(base_agent: @agent),
107
+ context: [],
108
+ strategy: Strategy::Simple.new
109
+ )
110
+ task.execute
111
+
112
+ @summary = Context::Summary.new(content: task.output)
113
+ @tasks = []
114
+ @initial_context = recent
115
+ @store.save(@session_id, context)
116
+ listener.emit(Event::CompressionEnd.new(status: "completed", entry_count: context.size))
117
+ rescue
118
+ listener&.emit(Event::CompressionEnd.new(status: "failed", entry_count: nil))
119
+ raise
120
+ end
121
+
122
+ def context_size
123
+ @token_counter.call(context.flat_map(&:to_message))
124
+ end
125
+
126
+ def context
127
+ base = @summary ? [@summary] : []
128
+ base + @initial_context + @tasks.select(&:completed?).flat_map(&:to_history)
129
+ end
130
+
131
+ private
132
+
133
+ def default_log_subscriber
134
+ case Rixie.config.log_format
135
+ when :json then Rixie::Subscribers::JsonLogger.new(logger: Rixie.config.logger)
136
+ else Rixie::Subscribers::Logger.new(logger: Rixie.config.logger)
137
+ end
138
+ end
139
+
140
+ def build_stream_subscriber(yielder)
141
+ sub = Object.new
142
+ sub.define_singleton_method(:subscribe) do |listener|
143
+ listener.on(Event::Token) { |envelope| yielder << envelope }
144
+ listener.on(Event::ThoughtCompleted) { |envelope| yielder << envelope }
145
+ listener.on(Event::Finished) { |envelope| yielder << envelope }
146
+ listener.on(Event::ToolCallStart) { |envelope| yielder << envelope }
147
+ listener.on(Event::ToolCallEnd) { |envelope| yielder << envelope }
148
+ listener.on(Event::ToolCallsCompleted) { |envelope| yielder << envelope }
149
+ end
150
+ sub
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Store
5
+ # Interface definition for storage adapters.
6
+ # Subclasses must implement #save and #load.
7
+ class Base
8
+ # Persists context for the given session_id.
9
+ # @param session_id [String]
10
+ # @param context [Array]
11
+ def save(session_id, context)
12
+ raise Rixie::NotImplementedError, "#{self.class}#save is not implemented"
13
+ end
14
+
15
+ # Retrieves context for the given session_id.
16
+ # @param session_id [String]
17
+ # @return [Array]
18
+ def load(session_id)
19
+ raise Rixie::NotImplementedError, "#{self.class}#load is not implemented"
20
+ end
21
+
22
+ # Serializes context for storage.
23
+ # @param context [Array<Context::History, Context::Summary>]
24
+ # @return [Array<Hash>]
25
+ def serialize(context)
26
+ raise Rixie::NotImplementedError, "#{self.class}#serialize is not implemented"
27
+ end
28
+
29
+ # Deserializes a single stored entry.
30
+ # @param entry [Hash] with a "type" key ("summary" or "history")
31
+ # @return [Context::History, Context::Summary]
32
+ def self.deserialize(entry)
33
+ raise Rixie::NotImplementedError, "#{self}.deserialize is not implemented"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Store
5
+ class Memory < Base
6
+ def initialize
7
+ @data = {}
8
+ end
9
+
10
+ def save(session_id, context)
11
+ @data[session_id] = context.map(&:to_store)
12
+ end
13
+
14
+ def load(session_id)
15
+ entries = @data.fetch(session_id, nil)
16
+ return [] if entries.nil?
17
+
18
+ entries.map { |entry| self.class.deserialize(entry) }
19
+ end
20
+
21
+ def self.deserialize(entry)
22
+ case entry["type"]
23
+ when "summary" then Context::Summary.from_store(entry)
24
+ when "history" then Context::History.from_store(entry)
25
+ else raise Rixie::Error, "Unknown context entry type: #{entry["type"]}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Store
5
+ class Null < Base
6
+ def save(session_id, context)
7
+ # no-op
8
+ end
9
+
10
+ def load(session_id)
11
+ []
12
+ end
13
+
14
+ def self.deserialize(entry)
15
+ []
16
+ end
17
+ end
18
+ end
19
+ end