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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Strategy
5
+ class PlanExecute
6
+ Plan = Data.define(:steps)
7
+
8
+ def run(task:, listener:)
9
+ plan = plan_phase(task:, listener:)
10
+ execute_phase(plan:, task:, listener:)
11
+ task.runs.last.output
12
+ end
13
+
14
+ private
15
+
16
+ def plan_phase(task:, listener:)
17
+ run = Run.new(
18
+ user_input: task.user_input,
19
+ agent: Agent::Plan.new(base_agent: task.agent),
20
+ context: task.context
21
+ )
22
+ task.runs << run
23
+ run.execute(listener:)
24
+ extract_plan(run)
25
+ end
26
+
27
+ def execute_phase(plan:, task:, listener:)
28
+ completed_histories = []
29
+
30
+ plan.steps.each do |step|
31
+ run = Run.new(
32
+ user_input: task.user_input,
33
+ agent: task.agent,
34
+ context: task.context + completed_histories + [
35
+ Context::Plan.new(steps: plan.steps, current_step: step)
36
+ ]
37
+ )
38
+ task.runs << run
39
+ run.execute(listener:)
40
+ completed_histories << run.to_history if run.completed?
41
+ end
42
+ end
43
+
44
+ def extract_plan(run)
45
+ plan_call = run.find_tool_call("plan_done")
46
+
47
+ raise AgentError, "plan_done tool call not found in run steps" if plan_call.nil?
48
+
49
+ raw_steps = plan_call.arguments[:steps] || plan_call.arguments["steps"]
50
+ if raw_steps.is_a?(String)
51
+ begin
52
+ raw_steps = JSON.parse(raw_steps)
53
+ rescue JSON::ParserError => e
54
+ raise AgentError, "plan_done returned invalid JSON for steps: #{e.message}"
55
+ end
56
+ end
57
+ steps = raw_steps.map do |s|
58
+ {title: s[:title] || s["title"], description: s[:description] || s["description"]}
59
+ end
60
+
61
+ Plan.new(steps: steps)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Strategy
5
+ class ReAct
6
+ def run(task:, listener:)
7
+ agent = Agent::ReAct.new(base_agent: task.agent)
8
+ run = Run.new(user_input: task.user_input, agent: agent, context: task.context)
9
+ task.runs << run
10
+ run.execute(listener:)
11
+ run.output
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Strategy
5
+ class Simple
6
+ def run(task:, listener:)
7
+ run = Run.new(user_input: task.user_input, agent: task.agent, context: task.context)
8
+ task.runs << run
9
+ run.execute(listener:)
10
+ run.output
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ class Subscriber
5
+ # Registers event handlers on the given listener.
6
+ # Called once per Task execution (new EventListener each time).
7
+ # Implement by calling listener.on(EventClass) { |e| ... } for each event of interest.
8
+ def subscribe(listener)
9
+ raise NotImplementedError, "#{self.class}#subscribe must be implemented"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Subscribers
5
+ # Maps an Event::* instance to the log severity (:debug, :info, :warn)
6
+ # at which subscribers should emit it. Shared by Subscribers::Logger and
7
+ # Subscribers::JsonLogger so the mapping does not drift between them.
8
+ module EventSeverity
9
+ def self.for(event)
10
+ case event
11
+ when Event::LlmCallStart, Event::ToolCallStart
12
+ :debug
13
+ when Event::ToolCallEnd
14
+ event.result.error? ? :warn : :debug
15
+ when Event::CompressionEnd
16
+ (event.status == "completed") ? :info : :warn
17
+ else
18
+ :info
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rixie
6
+ module Subscribers
7
+ class JsonLogger < Rixie::Subscriber
8
+ def initialize(logger:)
9
+ @logger = logger
10
+ end
11
+
12
+ def subscribe(listener)
13
+ listener.on(Event::TaskStart) { |envelope|
14
+ e = envelope.event
15
+ emit(envelope, "task_start", user_input: e.user_input, strategy: e.strategy.class.name)
16
+ }
17
+ listener.on(Event::TaskEnd) { |envelope|
18
+ emit(envelope, "task_end", status: envelope.event.status)
19
+ }
20
+ listener.on(Event::RunStart) { |envelope|
21
+ emit(envelope, "run_start", user_input: envelope.event.user_input)
22
+ }
23
+ listener.on(Event::RunEnd) { |envelope|
24
+ emit(envelope, "run_end", status: envelope.event.status)
25
+ }
26
+ listener.on(Event::CompressionStart) { |envelope|
27
+ e = envelope.event
28
+ emit(envelope, "compression_start", entry_count: e.entry_count, keep_recent: e.keep_recent)
29
+ }
30
+ listener.on(Event::CompressionEnd) { |envelope|
31
+ e = envelope.event
32
+ emit(envelope, "compression_end", status: e.status, entry_count: e.entry_count)
33
+ }
34
+ listener.on(Event::LlmCallStart) { |envelope|
35
+ emit(envelope, "llm_call_start", step_count: envelope.event.step_count)
36
+ }
37
+ listener.on(Event::ToolCallStart) { |envelope|
38
+ tc = envelope.event.tool_call
39
+ emit(envelope, "tool_call_start", tool_call: {id: tc.id, name: tc.name, arguments: tc.arguments})
40
+ }
41
+ listener.on(Event::ToolCallEnd) { |envelope|
42
+ tc = envelope.event.tool_call
43
+ r = envelope.event.result
44
+ emit(envelope, "tool_call_end",
45
+ tool_call: {id: tc.id, name: tc.name},
46
+ result: {content: r.content, error: r.error&.message})
47
+ }
48
+ listener.on(Event::Finished) { |envelope|
49
+ emit(envelope, "finished", content: envelope.event.content)
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def emit(envelope, type, **payload)
56
+ record = {
57
+ type: type,
58
+ occurred_at: envelope.occurred_at.iso8601,
59
+ session_id: envelope.session_id,
60
+ task_id: envelope.task_id,
61
+ run_id: envelope.run_id,
62
+ seq: envelope.sequence_number,
63
+ event_id: envelope.event_id,
64
+ payload: payload
65
+ }
66
+ @logger.public_send(EventSeverity.for(envelope.event)) { JSON.generate(record) }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module Subscribers
5
+ class Logger < Rixie::Subscriber
6
+ def initialize(logger:)
7
+ @logger = logger
8
+ end
9
+
10
+ def subscribe(listener)
11
+ listener.on(Event::TaskStart) { |envelope|
12
+ e = envelope.event
13
+ log(envelope) { "[Task] started: #{e.user_input.inspect} strategy=#{e.strategy.class.name} #{meta(envelope)}" }
14
+ }
15
+ listener.on(Event::TaskEnd) { |envelope|
16
+ log(envelope) { "[Task] #{envelope.event.status} #{meta(envelope)}" }
17
+ }
18
+ listener.on(Event::RunStart) { |envelope|
19
+ log(envelope) { "[Run] started: #{envelope.event.user_input.inspect} #{meta(envelope)}" }
20
+ }
21
+ listener.on(Event::RunEnd) { |envelope|
22
+ log(envelope) { "[Run] #{envelope.event.status} #{meta(envelope)}" }
23
+ }
24
+ listener.on(Event::CompressionStart) { |envelope|
25
+ e = envelope.event
26
+ log(envelope) { "[Session] compression started: #{e.entry_count} entries (keep_recent: #{e.keep_recent}) #{meta(envelope)}" }
27
+ }
28
+ listener.on(Event::CompressionEnd) { |envelope|
29
+ e = envelope.event
30
+ msg = (e.status == "completed") ? "compression completed: #{e.entry_count} entries after" : "compression failed"
31
+ log(envelope) { "[Session] #{msg} #{meta(envelope)}" }
32
+ }
33
+ listener.on(Event::LlmCallStart) { |envelope|
34
+ log(envelope) { "[Agent] llm_call ##{envelope.event.step_count} #{meta(envelope)}" }
35
+ }
36
+ listener.on(Event::ToolCallStart) { |envelope|
37
+ e = envelope.event
38
+ log(envelope) { "[Agent] tool_call: #{e.tool_call.name}(#{e.tool_call.arguments}) #{meta(envelope)}" }
39
+ }
40
+ listener.on(Event::ToolCallEnd) { |envelope|
41
+ log(envelope) { "[Agent] tool_result: #{envelope.event.result.content.inspect} #{meta(envelope)}" }
42
+ }
43
+ listener.on(Event::Finished) { |envelope|
44
+ log(envelope) { "[Agent] finish: #{envelope.event.content.inspect} #{meta(envelope)}" }
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def log(envelope, &block)
51
+ @logger.public_send(EventSeverity.for(envelope.event), &block)
52
+ end
53
+
54
+ def meta(envelope)
55
+ parts = []
56
+ parts << "session_id=#{envelope.session_id}" if envelope.session_id
57
+ parts << "task_id=#{envelope.task_id}" if envelope.task_id
58
+ parts << "run_id=#{envelope.run_id}" if envelope.run_id
59
+ parts << "seq=#{envelope.sequence_number}"
60
+ parts << "event_id=#{envelope.event_id}"
61
+ "[#{parts.join(" ")}]"
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/rixie/task.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Rixie
6
+ class Task
7
+ attr_reader :id, :user_input, :agent, :context, :strategy, :runs, :status, :output
8
+
9
+ def initialize(user_input:, agent:, context:, strategy:, subscribers: [], session_id: nil)
10
+ @id = SecureRandom.uuid
11
+ @session_id = session_id
12
+ @user_input = user_input
13
+ @agent = agent
14
+ @context = context
15
+ @strategy = strategy
16
+ @subscribers = subscribers
17
+ @runs = []
18
+ @status = "running"
19
+ @output = nil
20
+ end
21
+
22
+ def execute
23
+ listener = EventListener.new(session_id: @session_id, task_id: @id)
24
+ listener.on(Event::ToolCallsCompleted) { |envelope|
25
+ e = envelope.event
26
+ runs.last.add_step(tool_calls: e.tool_calls, tool_results: e.tool_results)
27
+ }
28
+ @subscribers.each { |s| s.subscribe(listener) }
29
+ listener.emit(Event::TaskStart.new(user_input: @user_input, strategy: @strategy))
30
+
31
+ result = @strategy.run(task: self, listener:)
32
+ @output = result
33
+ @status = "completed"
34
+ listener.emit(Event::TaskEnd.new(output: @output, status: @status))
35
+ rescue
36
+ @status = "failed"
37
+ listener&.emit(Event::TaskEnd.new(output: nil, status: @status))
38
+ raise
39
+ end
40
+
41
+ def completed?
42
+ @status == "completed"
43
+ end
44
+
45
+ def failed?
46
+ @status == "failed"
47
+ end
48
+
49
+ def to_history
50
+ @runs.select(&:completed?).map(&:to_history)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ module TokenCounter
5
+ # Approximate token count using character length (1 token ≈ 4 characters).
6
+ # Replace via Rixie.configure { |c| c.token_counter = your_callable }
7
+ # to use an exact counter such as tiktoken.
8
+ DEFAULT = ->(messages) { messages.sum { |m| m.content.to_s.length } / 4 }
9
+ end
10
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Rixie
6
+ class Tool
7
+ # Recursive-descent parser for arithmetic expressions.
8
+ # Supports: + - * / % ^ ** (), unary minus/plus, ints / floats / scientific notation.
9
+ class CalculatorParser
10
+ class Error < StandardError; end
11
+
12
+ ADDITIVE = ["+", "-"].freeze
13
+ MULTIPLICATIVE = ["*", "/", "%"].freeze
14
+ POWER = ["**", "^"].freeze
15
+
16
+ def self.evaluate(expression)
17
+ new(expression).parse
18
+ end
19
+
20
+ def initialize(expression)
21
+ @tokens = tokenize(expression)
22
+ @pos = 0
23
+ end
24
+
25
+ def parse
26
+ result = expression
27
+ raise Error, "Unexpected token: #{peek.inspect}" if peek
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def tokenize(input)
34
+ scanner = StringScanner.new(input.to_s)
35
+ tokens = []
36
+ until scanner.eos?
37
+ scanner.skip(/\s+/)
38
+ break if scanner.eos?
39
+
40
+ if (num = scanner.scan(/\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/))
41
+ tokens << (num.match?(/[.eE]/) ? num.to_f : num.to_i)
42
+ elsif (op = scanner.scan(/\*\*|[+\-*\/%^()]/))
43
+ tokens << op
44
+ else
45
+ raise Error, "Invalid character at position #{scanner.pos}: #{scanner.peek(1).inspect}"
46
+ end
47
+ end
48
+ tokens
49
+ end
50
+
51
+ def peek = @tokens[@pos]
52
+
53
+ def consume
54
+ token = @tokens[@pos]
55
+ @pos += 1
56
+ token
57
+ end
58
+
59
+ def expression
60
+ left = term
61
+ while ADDITIVE.include?(peek)
62
+ op = consume
63
+ right = term
64
+ left = (op == "+") ? left + right : left - right
65
+ end
66
+ left
67
+ end
68
+
69
+ def term
70
+ left = power
71
+ while MULTIPLICATIVE.include?(peek)
72
+ op = consume
73
+ right = power
74
+ left = apply_multiplicative(op, left, right)
75
+ end
76
+ left
77
+ end
78
+
79
+ def apply_multiplicative(op, left, right)
80
+ case op
81
+ when "*" then left * right
82
+ when "/"
83
+ raise Error, "Division by zero" if right.zero?
84
+ # Promote integer division to float to avoid surprising truncation.
85
+ (left.is_a?(Integer) && right.is_a?(Integer)) ? left.fdiv(right) : left / right
86
+ when "%"
87
+ raise Error, "Modulo by zero" if right.zero?
88
+ left % right
89
+ end
90
+ end
91
+
92
+ def power
93
+ left = unary
94
+ if POWER.include?(peek)
95
+ consume
96
+ right = power # right-associative
97
+ left **= right
98
+ end
99
+ left
100
+ end
101
+
102
+ def unary
103
+ case peek
104
+ when "-" then consume
105
+ -unary
106
+ when "+" then consume
107
+ unary
108
+ else primary
109
+ end
110
+ end
111
+
112
+ def primary
113
+ token = consume
114
+ return parse_parenthesized if token == "("
115
+ return token if token.is_a?(Numeric)
116
+
117
+ raise Error, "Unexpected token: #{token.inspect}"
118
+ end
119
+
120
+ def parse_parenthesized
121
+ value = expression
122
+ raise Error, "Expected ')'" unless consume == ")"
123
+ value
124
+ end
125
+ end
126
+ private_constant :CalculatorParser
127
+
128
+ Calculator = Tool.new(
129
+ name: "calculator",
130
+ description: "Evaluate an arithmetic expression and return the result. " \
131
+ "Supports + - * / % and ^ (or **) for exponentiation, plus parentheses. " \
132
+ "Use this for any non-trivial arithmetic — LLMs are unreliable at calculation.",
133
+ input_schema: {
134
+ type: "object",
135
+ properties: {
136
+ expression: {
137
+ type: "string",
138
+ description: "An arithmetic expression, e.g. '(2 + 3) * 4 ^ 2'"
139
+ }
140
+ },
141
+ required: ["expression"]
142
+ },
143
+ call: ->(args) {
144
+ begin
145
+ expr = args["expression"] || args[:expression]
146
+ result = CalculatorParser.evaluate(expr)
147
+ result.to_s
148
+ rescue CalculatorParser::Error => e
149
+ "Error: #{e.message}"
150
+ end
151
+ }
152
+ )
153
+ end
154
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Rixie
6
+ class Tool
7
+ CurrentTime = Tool.new(
8
+ name: "current_time",
9
+ description: "Get the current date and time as an ISO 8601 string. " \
10
+ "LLMs do not know the current time on their own — call this " \
11
+ "when the user asks about \"now\", \"today\", relative dates, " \
12
+ "or anything time-sensitive.",
13
+ input_schema: {
14
+ type: "object",
15
+ properties: {
16
+ timezone: {
17
+ type: "string",
18
+ description: "Either 'local' (system local time) or 'utc'. Defaults to 'local'.",
19
+ enum: ["local", "utc"]
20
+ }
21
+ }
22
+ },
23
+ call: ->(args) {
24
+ tz = (args["timezone"] || args[:timezone] || "local").to_s.downcase
25
+ time = (tz == "utc") ? Time.now.utc : Time.now
26
+ time.iso8601
27
+ }
28
+ )
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ class Tool
5
+ Fetch = Tool.new(
6
+ name: "fetch",
7
+ description: "Fetch the content of a URL and return the readable text. Useful for reading web pages found via web_search.",
8
+ input_schema: {
9
+ type: "object",
10
+ properties: {
11
+ url: {
12
+ type: "string",
13
+ description: "The URL to fetch"
14
+ }
15
+ },
16
+ required: ["url"]
17
+ },
18
+ call: ->(args) {
19
+ begin
20
+ require "nokogiri"
21
+ rescue LoadError
22
+ raise Rixie::ConfigurationError, "nokogiri gem is required for Tool::Fetch. Add `gem 'nokogiri'` to your Gemfile."
23
+ end
24
+
25
+ url = args["url"] || args[:url]
26
+ response = Rixie::Http::Client.new.get(url)
27
+ content_type = response[:headers]["content-type"]&.first.to_s
28
+
29
+ next response[:body] unless content_type.include?("text/html")
30
+
31
+ doc = Nokogiri::HTML(response[:body].to_s)
32
+ doc.css("nav, script, style, footer, header, aside, img, link, figure, blockquote, button, noscript, iframe").remove
33
+ doc.css("pre").each { |pre| pre.replace("[code block omitted]") }
34
+ doc.css("body").text
35
+ .gsub(/[^\S\n]+/, " ")
36
+ .gsub(/^ +| +$/, "")
37
+ .gsub(/\n{3,}/, "\n\n")
38
+ .strip
39
+ }
40
+ )
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_sandbox"
4
+
5
+ module Rixie
6
+ class Tool
7
+ build = ->(root_dir: nil) {
8
+ Tool.new(
9
+ name: "file_list",
10
+ description: "List files matching a glob pattern within the configured root directory. " \
11
+ "Patterns are relative to root_dir; matches that escape root_dir are filtered out.",
12
+ input_schema: {
13
+ type: "object",
14
+ properties: {
15
+ pattern: {
16
+ type: "string",
17
+ description: "Glob pattern, e.g. '**/*.rb', 'src/*.js', 'lib/**/*'"
18
+ }
19
+ },
20
+ required: ["pattern"]
21
+ },
22
+ call: ->(args) {
23
+ base = FileSandbox.root(root_dir)
24
+ pattern = (args["pattern"] || args[:pattern]).to_s
25
+ matches = Dir.glob(pattern, base: base).select do |rel|
26
+ FileSandbox.resolve(base, rel)
27
+ true
28
+ rescue FileSandbox::PathError
29
+ false
30
+ end
31
+ matches.sort!
32
+ matches.empty? ? "No files matched." : matches.join("\n")
33
+ }
34
+ )
35
+ }
36
+ FileList = build.call
37
+ FileList.define_singleton_method(:with, &build)
38
+ end
39
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_sandbox"
4
+
5
+ module Rixie
6
+ class Tool
7
+ DEFAULT_FILE_READ_LIMIT = 2000
8
+ private_constant :DEFAULT_FILE_READ_LIMIT
9
+
10
+ build = ->(root_dir: nil) {
11
+ Tool.new(
12
+ name: "file_read",
13
+ description: "Read a text file from the configured root directory. " \
14
+ "Paths are relative to root_dir; absolute or '..' paths that " \
15
+ "escape root_dir are rejected. Binary files are not returned.",
16
+ input_schema: {
17
+ type: "object",
18
+ properties: {
19
+ path: {
20
+ type: "string",
21
+ description: "Path to the file, relative to root_dir"
22
+ },
23
+ offset: {
24
+ type: "integer",
25
+ description: "Line number to start reading from (1-indexed). Defaults to 1."
26
+ },
27
+ limit: {
28
+ type: "integer",
29
+ description: "Maximum number of lines to read. Defaults to #{DEFAULT_FILE_READ_LIMIT}."
30
+ }
31
+ },
32
+ required: ["path"]
33
+ },
34
+ call: ->(args) {
35
+ begin
36
+ rel_path = args["path"] || args[:path]
37
+ target = FileSandbox.resolve(root_dir, rel_path)
38
+ next "Error: File not found: #{rel_path}" unless File.file?(target)
39
+ next "Error: Binary file not supported: #{rel_path}" if FileSandbox.binary?(target)
40
+
41
+ offset = (args["offset"] || args[:offset] || 1).to_i
42
+ limit = (args["limit"] || args[:limit] || DEFAULT_FILE_READ_LIMIT).to_i
43
+ File.foreach(target).drop(offset - 1).take(limit).join
44
+ rescue FileSandbox::PathError => e
45
+ "Error: #{e.message}"
46
+ end
47
+ }
48
+ )
49
+ }
50
+ FileRead = build.call
51
+ FileRead.define_singleton_method(:with, &build)
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rixie
4
+ class Tool
5
+ # Shared path resolution + safety check for file_read / file_list / file_search.
6
+ # Rejects paths that escape the configured root directory after expansion.
7
+ module FileSandbox
8
+ class PathError < StandardError; end
9
+
10
+ def self.root(root_dir)
11
+ File.expand_path(root_dir || Dir.pwd)
12
+ end
13
+
14
+ def self.resolve(root_dir, relative_path)
15
+ segments = relative_path.to_s.split(%r{[/\\]})
16
+ raise PathError, "Path '#{relative_path}' contains '..' segment" if segments.include?("..")
17
+
18
+ base = root(root_dir)
19
+ target = File.expand_path(relative_path.to_s, base)
20
+ return target if target == base || target.start_with?(base + File::SEPARATOR)
21
+
22
+ raise PathError, "Path '#{relative_path}' is outside root_dir"
23
+ end
24
+
25
+ BINARY_PROBE_BYTES = 8192
26
+ private_constant :BINARY_PROBE_BYTES
27
+
28
+ def self.binary?(path)
29
+ File.open(path, "rb") { |f| f.read(BINARY_PROBE_BYTES).to_s.include?("\0") }
30
+ end
31
+ end
32
+ end
33
+ end