zephira 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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +36 -0
  4. data/Dockerfile +39 -0
  5. data/Gemfile +5 -0
  6. data/Gemfile.lock +145 -0
  7. data/README.md +230 -0
  8. data/Rakefile +12 -0
  9. data/exe/zephira +6 -0
  10. data/lib/zephira/agent/status.rb +20 -0
  11. data/lib/zephira/agent.rb +312 -0
  12. data/lib/zephira/backends/open_ai_compatible.rb +74 -0
  13. data/lib/zephira/backends.rb +17 -0
  14. data/lib/zephira/cli.rb +41 -0
  15. data/lib/zephira/commands/about.rb +27 -0
  16. data/lib/zephira/commands/bye.rb +22 -0
  17. data/lib/zephira/commands/clear.rb +32 -0
  18. data/lib/zephira/commands/compact.rb +22 -0
  19. data/lib/zephira/commands/help.rb +22 -0
  20. data/lib/zephira/commands/history.rb +25 -0
  21. data/lib/zephira/commands/model.rb +51 -0
  22. data/lib/zephira/commands.rb +31 -0
  23. data/lib/zephira/completions/file_names.rb +22 -0
  24. data/lib/zephira/completions/slash_commands.rb +17 -0
  25. data/lib/zephira/completions.rb +22 -0
  26. data/lib/zephira/config.rb +17 -0
  27. data/lib/zephira/formatter.rb +68 -0
  28. data/lib/zephira/history.rb +117 -0
  29. data/lib/zephira/logger.rb +46 -0
  30. data/lib/zephira/models/base_model.rb +143 -0
  31. data/lib/zephira/models/chat_gpt41.rb +15 -0
  32. data/lib/zephira/models/chat_gpt41_mini.rb +15 -0
  33. data/lib/zephira/models/claude_35_sonnet.rb +15 -0
  34. data/lib/zephira/models/gpt_5_4.rb +15 -0
  35. data/lib/zephira/models/gpt_5_5.rb +15 -0
  36. data/lib/zephira/models/gpt_o4_mini.rb +15 -0
  37. data/lib/zephira/models/llama4.rb +15 -0
  38. data/lib/zephira/models.rb +19 -0
  39. data/lib/zephira/sandbox.rb +193 -0
  40. data/lib/zephira/tokens.rb +16 -0
  41. data/lib/zephira/tools/base_tool.rb +105 -0
  42. data/lib/zephira/tools/code_search.rb +135 -0
  43. data/lib/zephira/tools/delete_file.rb +47 -0
  44. data/lib/zephira/tools/http_request.rb +112 -0
  45. data/lib/zephira/tools/list_directory.rb +57 -0
  46. data/lib/zephira/tools/memory_delete.rb +39 -0
  47. data/lib/zephira/tools/memory_list.rb +37 -0
  48. data/lib/zephira/tools/memory_read.rb +43 -0
  49. data/lib/zephira/tools/memory_store.rb +51 -0
  50. data/lib/zephira/tools/memory_write.rb +39 -0
  51. data/lib/zephira/tools/read_file.rb +90 -0
  52. data/lib/zephira/tools/shell.rb +82 -0
  53. data/lib/zephira/tools/update_file.rb +46 -0
  54. data/lib/zephira/tools/web_search.rb +113 -0
  55. data/lib/zephira/tools.rb +70 -0
  56. data/lib/zephira/version.rb +5 -0
  57. data/lib/zephira.rb +24 -0
  58. data/license.txt +21 -0
  59. data/standard.yml +3 -0
  60. metadata +243 -0
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "io/console"
5
+
6
+ module Zephira
7
+ class Sandbox
8
+ GHCR_IMAGE = "ghcr.io/aarongough/zephira"
9
+ DERIVED_IMAGE_PREFIX = "zephira-sandbox"
10
+
11
+ FORWARDED_ENV_PATTERNS = [/\AZEPHIRA_/].freeze
12
+ FORWARDED_ENV_EXCLUDES = %w[ZEPHIRA_IN_SANDBOX ZEPHIRA_SANDBOX].freeze
13
+
14
+ OUTER_TL = "╔"
15
+ OUTER_TR = "╗"
16
+ OUTER_BL = "╚"
17
+ OUTER_BR = "╝"
18
+ OUTER_H = "═"
19
+ OUTER_V = "║"
20
+
21
+ INNER_TL = "┌"
22
+ INNER_TR = "┐"
23
+ INNER_BL = "└"
24
+ INNER_BR = "┘"
25
+ INNER_H = "─"
26
+ INNER_V = "│"
27
+ INNER_PADDING = 3
28
+
29
+ class << self
30
+ def exec_if_needed!(argv)
31
+ return if ENV["ZEPHIRA_IN_SANDBOX"] == "1"
32
+ return if ENV["ZEPHIRA_SANDBOX"] == "false"
33
+
34
+ abort_with_sandbox_error unless docker_available?
35
+
36
+ target = resolve_image
37
+ $stderr.puts "[Zephira] Launching in Docker sandbox (#{target})..."
38
+ Kernel.exec(*build_docker_command(argv, target))
39
+ end
40
+
41
+ private
42
+
43
+ def abort_with_sandbox_error
44
+ width = terminal_width
45
+
46
+ warn_lines = [
47
+ "",
48
+ "#{Formatter.color(:red, "⚠")} #{Formatter.color(:red, "WARNING:")} Without the sandbox the agent has direct access to",
49
+ "your host filesystem. Files it creates, modifies, or deletes",
50
+ "affect your real system with no isolation or undo. Only skip",
51
+ "the sandbox if you understand and accept this risk.",
52
+ ""
53
+ ]
54
+
55
+ instruction_lines = [
56
+ "",
57
+ " #{Formatter.color(:red, "ERROR:")} Zephira requires Docker to run safely in a sandboxed environment.",
58
+ "",
59
+ " Docker was not found or is not currently running. To fix this:",
60
+ "",
61
+ " 1. Install Docker Desktop: https://docs.docker.com/get-docker/",
62
+ " 2. Start Docker and confirm it is running: docker info",
63
+ "",
64
+ " To bypass the sandbox (not recommended):",
65
+ "",
66
+ " zephira --dangerously-skip-sandbox",
67
+ ""
68
+ ]
69
+
70
+ max_warn_width = warn_lines.map { |line| visible_length(line) }.max
71
+ max_content_width = instruction_lines.map { |line| visible_length(line) }.max
72
+ inner_width = [max_warn_width, max_content_width - 10].max
73
+
74
+ inner_box = [
75
+ " " + inner_top(inner_width),
76
+ *warn_lines.map { |line| " " + inner_row(line, inner_width) },
77
+ " " + inner_bottom(inner_width)
78
+ ]
79
+
80
+ content = [*instruction_lines, *inner_box, ""]
81
+
82
+ [
83
+ outer_top(width),
84
+ *content.map { |line| outer_row(line, width) },
85
+ outer_bottom(width),
86
+ ""
87
+ ].each { |line| $stderr.puts line }
88
+ exit(1)
89
+ end
90
+
91
+ def outer_top(width)
92
+ Formatter.color(:red, OUTER_TL + OUTER_H * (width - 2) + OUTER_TR)
93
+ end
94
+
95
+ def outer_bottom(width)
96
+ Formatter.color(:red, OUTER_BL + OUTER_H * (width - 2) + OUTER_BR)
97
+ end
98
+
99
+ def outer_row(text, width)
100
+ padding = " " * [width - 2 - visible_length(text), 0].max
101
+ Formatter.color(:red, OUTER_V) + text + padding + Formatter.color(:red, OUTER_V)
102
+ end
103
+
104
+ def inner_top(max_width)
105
+ Formatter.color(:red, INNER_TL + INNER_H * (max_width + INNER_PADDING * 2) + INNER_TR)
106
+ end
107
+
108
+ def inner_bottom(max_width)
109
+ Formatter.color(:red, INNER_BL + INNER_H * (max_width + INNER_PADDING * 2) + INNER_BR)
110
+ end
111
+
112
+ def inner_row(text, max_width)
113
+ padding = " " * [max_width - visible_length(text), 0].max
114
+ pad = " " * INNER_PADDING
115
+ Formatter.color(:red, INNER_V) + pad + text + padding + pad + Formatter.color(:red, INNER_V)
116
+ end
117
+
118
+ def visible_length(str)
119
+ str.gsub(/\e\[[0-9;]*m/, "").length
120
+ end
121
+
122
+ def terminal_width
123
+ IO.console&.winsize&.last || 80
124
+ rescue StandardError
125
+ 80
126
+ end
127
+
128
+ def docker_available?
129
+ system("docker info > /dev/null 2>&1")
130
+ end
131
+
132
+ def resolve_image
133
+ base = Config.read("ZEPHIRA_BASE_IMAGE")
134
+ return "#{GHCR_IMAGE}:#{VERSION}" unless base
135
+
136
+ derived = derived_image_name(base)
137
+ unless image_exists?(derived)
138
+ $stderr.puts "[Zephira] Building sandbox image from #{base}..."
139
+ build_derived_image(base, derived)
140
+ end
141
+ derived
142
+ end
143
+
144
+ def derived_image_name(base_image)
145
+ sanitized = base_image.gsub(/[^a-zA-Z0-9._-]/, "-")
146
+ "#{DERIVED_IMAGE_PREFIX}-#{sanitized}:#{VERSION}"
147
+ end
148
+
149
+ def image_exists?(name)
150
+ system("docker image inspect #{name} > /dev/null 2>&1")
151
+ end
152
+
153
+ def build_derived_image(base_image, target_name)
154
+ dockerfile = "FROM #{base_image}\nRUN gem install zephira:#{VERSION} --no-document\n"
155
+ Tempfile.create(["zephira-sandbox", ".dockerfile"]) do |file|
156
+ file.write(dockerfile)
157
+ file.flush
158
+ system("docker build -t #{target_name} -f #{file.path} .")
159
+ end
160
+ end
161
+
162
+ def forwarded_env_keys
163
+ ENV.keys
164
+ .reject { |key| FORWARDED_ENV_EXCLUDES.include?(key) }
165
+ .select { |key| FORWARDED_ENV_PATTERNS.any? { |pattern| key.match?(pattern) } }
166
+ .sort
167
+ end
168
+
169
+ def build_docker_command(argv, image)
170
+ cmd = ["docker", "run", "--rm", "-i"]
171
+ cmd << "-t" if $stdout.tty?
172
+
173
+ cmd += ["-e", "ZEPHIRA_IN_SANDBOX=1"]
174
+ cmd += ["-v", "#{Dir.pwd}:/workspace:rw"]
175
+
176
+ global_config = File.expand_path("~/.zephira.yml")
177
+ cmd += ["-v", "#{global_config}:/root/.zephira.yml:ro"] if File.exist?(global_config)
178
+
179
+ global_dir = File.expand_path("~/.zephira")
180
+ cmd += ["-v", "#{global_dir}:/root/.zephira:ro"] if File.exist?(global_dir) && File.directory?(global_dir)
181
+
182
+ forwarded_env_keys.each do |key|
183
+ cmd += ["-e", "#{key}=#{ENV[key]}"]
184
+ end
185
+
186
+ cmd += ["-w", "/workspace"]
187
+ cmd << image
188
+ cmd += ["zephira"] + argv
189
+ cmd
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ # Approximates token counts for arbitrary text without pulling in a real
5
+ # tokenizer dependency. Counts word-like runs and standalone punctuation,
6
+ # which lands within ~20% of real BPE tokenizers (GPT/Claude) for English
7
+ # text — close enough for context-budget decisions, never trust for billing.
8
+ module Tokens
9
+ TOKEN_PATTERN = /\w+|[^\s\w]/.freeze
10
+
11
+ def self.estimate(text)
12
+ return 0 if text.nil? || text.empty?
13
+ text.to_s.scan(TOKEN_PATTERN).size
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ class Tools
5
+ class BaseTool
6
+ class ToolUseError < StandardError; end
7
+
8
+ attr_reader :args, :agent
9
+
10
+ def initialize(args:, agent:)
11
+ @args = args
12
+ @agent = agent
13
+ end
14
+
15
+ def arg(name)
16
+ @args[name] || @args[name.to_s]
17
+ end
18
+
19
+ def validate(actual, arg_path:, type:, allow_nil: false, allow_empty: false)
20
+ if !allow_nil && actual.nil?
21
+ raise ToolUseError, "argument `#{arg_path}` must be supplied"
22
+ end
23
+
24
+ unless actual.is_a?(type)
25
+ raise ToolUseError, "argument `#{arg_path}` must be of type #{type} and non-empty"
26
+ end
27
+
28
+ if !allow_empty && type == String && actual.strip.empty?
29
+ raise ToolUseError, "argument `#{arg_path}` must be of type #{type} and non-empty"
30
+ end
31
+
32
+ if !allow_empty && actual.respond_to?(:empty?) && actual.empty?
33
+ raise ToolUseError, "argument `#{arg_path}` must be of type #{type} and non-empty"
34
+ end
35
+
36
+ actual
37
+ end
38
+
39
+ def run
40
+ raise NotImplementedError, "This method should be overridden in a subclass"
41
+ end
42
+
43
+ def error_result(message:)
44
+ {outcome: "error", error: message, data: nil}
45
+ end
46
+
47
+ def success_result(data)
48
+ {outcome: "success", error: nil, data: data}
49
+ end
50
+
51
+ class << self
52
+ def run(args:, agent:)
53
+ result = begin
54
+ tool_instance = new(args: args, agent: agent)
55
+ intent_value = tool_instance.arg(:intent)
56
+ tool_instance.validate(intent_value, arg_path: "args[:intent]", type: String)
57
+ agent.update_status(Formatter.color(:green, "→ ") + intent_value) if announces_intent?
58
+ tool_instance.run
59
+ rescue => exception
60
+ log_message = "Tool call `#{name}` with args `#{args.inspect}` returned error: #{exception.message}"
61
+ agent.logger.warn(log_message)
62
+ agent.status.warn("ERROR: Tool call `#{name}` returned error: #{exception.message}")
63
+ return {outcome: "error", error: exception.message, data: nil}
64
+ end
65
+
66
+ if result[:outcome] == "success"
67
+ agent.logger.info("Tool call `#{name}` with args `#{args.inspect}` completed successfully: #{result[:data]}")
68
+ agent.status.verbose("Tool call `#{name}` completed successfully")
69
+ elsif result[:outcome] == "error"
70
+ agent.logger.warn("Tool call `#{name}` with args `#{args.inspect}` returned error: #{result[:error]}")
71
+ agent.status.warn("ERROR: Tool call `#{name}` returned error: #{result[:error]}")
72
+ end
73
+
74
+ result
75
+ end
76
+
77
+ def name
78
+ raise NotImplementedError, "This method should be overridden in a subclass"
79
+ end
80
+
81
+ def description
82
+ raise NotImplementedError, "This method should be overridden in a subclass"
83
+ end
84
+
85
+ def parameters
86
+ raise NotImplementedError, "This method should be overridden in a subclass"
87
+ end
88
+
89
+ # Override and return true in read-only tools so the agent can run them
90
+ # concurrently. Anything that mutates filesystem, network state, memory
91
+ # store, or agent state must remain false (the default).
92
+ def read_only?
93
+ false
94
+ end
95
+
96
+ # Override to false in tools that emit their own per-item status lines
97
+ # (e.g. one line per query). Suppresses the auto-printed `→ intent`
98
+ # line that BaseTool.run emits before #run.
99
+ def announces_intent?
100
+ true
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+
6
+ module Zephira
7
+ class Tools
8
+ class CodeSearch < BaseTool
9
+ class << self
10
+ def name
11
+ "code_search"
12
+ end
13
+
14
+ def description
15
+ "Search codebase for symbols or patterns"
16
+ end
17
+
18
+ def read_only?
19
+ true
20
+ end
21
+
22
+ def parameters
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ queries: {
27
+ type: "array",
28
+ items: {
29
+ type: "object",
30
+ properties: {
31
+ query: {type: "string", description: "String to search for"},
32
+ path: {type: "string", description: "Directory path to search"},
33
+ case_sensitive: {type: "boolean", description: "Enable case-sensitive search"},
34
+ max_results: {type: "integer", description: "Maximum number of results"}
35
+ },
36
+ required: ["query", "path"]
37
+ }
38
+ },
39
+ intent: {
40
+ type: "string",
41
+ description: "Brief summary of intent of the operation, meant to be used for context compaction and presentation to the user. Use active voice (e.g., 'Reading X to do Y')."
42
+ }
43
+ },
44
+ required: ["queries", "intent"]
45
+ }
46
+ end
47
+ end
48
+
49
+ def run
50
+ queries = arg(:queries)
51
+ unless queries.is_a?(Array) && !queries.empty?
52
+ return error_result(message: "argument `queries` must be a non-empty array")
53
+ end
54
+
55
+ results = queries.map { |query_args| run_query(query_args) }
56
+ success_result(results)
57
+ end
58
+
59
+ private
60
+
61
+ def run_query(query_args)
62
+ query = query_args[:query] || query_args["query"]
63
+ path = query_args[:path] || query_args["path"]
64
+ case_sensitive = query_args[:case_sensitive] || query_args["case_sensitive"]
65
+ max_results = query_args[:max_results] || query_args["max_results"]
66
+
67
+ return error_result(message: "Path must be provided") if path.nil? || path.to_s.strip.empty?
68
+
69
+ expanded_path = ::File.expand_path(path.to_s)
70
+ return error_result(message: "Path not found: #{expanded_path}") unless ::Dir.exist?(expanded_path)
71
+
72
+ return error_result(message: "Query must be a non-empty string") if query.nil? || !query.is_a?(String) || query.strip.empty?
73
+ return error_result(message: "ripgrep (rg) not found") unless executable_available?("rg")
74
+
75
+ agent.status.verbose(" • Text search for '#{query}' in '#{path}'")
76
+
77
+ cmd = ["rg", "--json", "-C", "2", "-n"]
78
+ cmd << "-i" unless case_sensitive
79
+ cmd << query
80
+ cmd << expanded_path
81
+
82
+ stdout, stderr, status = Open3.capture3(*cmd)
83
+ return error_result(message: "ripgrep failed: #{stderr.strip}") unless status.success?
84
+
85
+ results = parse_rg_output(stdout, max_results)
86
+ agent.status.verbose(" • Code search completed: found #{results.size} matches")
87
+ success_result(results)
88
+ end
89
+
90
+ def parse_rg_output(stdout, max_results)
91
+ results = []
92
+ context_buffer = []
93
+ current_file = nil
94
+
95
+ stdout.each_line do |line|
96
+ data = begin
97
+ JSON.parse(line)
98
+ rescue JSON::ParserError
99
+ next
100
+ end
101
+
102
+ case data["type"]
103
+ when "begin"
104
+ current_file = data.dig("data", "path", "text")
105
+ context_buffer = []
106
+ when "match"
107
+ context_buffer << {
108
+ file: current_file,
109
+ line: data["data"]["line_number"],
110
+ content: data["data"]["lines"]["text"],
111
+ match: true
112
+ }
113
+ when "context"
114
+ context_buffer << {
115
+ file: current_file,
116
+ line: data["data"]["line_number"],
117
+ content: data["data"]["lines"]["text"],
118
+ match: false
119
+ }
120
+ when "end"
121
+ results << context_buffer.sort_by { |entry| entry[:line] }
122
+ break if max_results && results.size >= max_results
123
+ end
124
+ end
125
+
126
+ results
127
+ end
128
+
129
+ def executable_available?(cmd)
130
+ _, _, status = Open3.capture3("command", "-v", cmd)
131
+ status.success?
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Zephira
6
+ class Tools
7
+ class DeleteFile < BaseTool
8
+ class << self
9
+ def name
10
+ "delete_file"
11
+ end
12
+
13
+ def description
14
+ "Delete a file or directory and its contents."
15
+ end
16
+
17
+ def parameters
18
+ {
19
+ type: "object",
20
+ properties: {
21
+ intent: {type: "string", description: "Brief summary of intent of the operation, meant to be used for context compaction and presentation to the user. Use active voice (e.g., 'Reading X to do Y')."},
22
+ file_path: {type: "string", description: "Path to the file or directory to delete"}
23
+ },
24
+ required: ["file_path", "intent"]
25
+ }
26
+ end
27
+ end
28
+
29
+ def run
30
+ path = validate(arg(:file_path), arg_path: "file_path", type: String)
31
+
32
+ agent.status.verbose(" • Deleting file or directory: '#{path}'")
33
+
34
+ expanded = ::File.expand_path(path)
35
+ begin
36
+ ::FileUtils.rm_rf(expanded)
37
+ rescue Errno::EACCES
38
+ return error_result(message: "Permission denied: #{path}")
39
+ end
40
+
41
+ agent.status.verbose(" • File or dir deleted: '#{path}'")
42
+ agent.logger.info("File or dir deleted: '#{path}'")
43
+ success_result("File or dir deleted: '#{path}'")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Zephira
7
+ class Tools
8
+ class HttpRequest < BaseTool
9
+ class << self
10
+ def name
11
+ "http_request"
12
+ end
13
+
14
+ def description
15
+ "Perform HTTP requests: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS"
16
+ end
17
+
18
+ def parameters
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ intent: {
23
+ type: "string",
24
+ description: "Brief summary of intent of the operation, meant to be used for context compaction and presentation to the user. Use active voice (e.g., 'Reading X to do Y')."
25
+ },
26
+ method: {
27
+ type: "string",
28
+ enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
29
+ },
30
+ url: {
31
+ type: "string",
32
+ description: "Request URL"
33
+ },
34
+ headers: {
35
+ type: "object",
36
+ description: "Request headers as key-value pairs"
37
+ },
38
+ query: {
39
+ type: "object",
40
+ description: "Query parameters as key-value pairs"
41
+ },
42
+ body: {
43
+ type: ["string", "object"],
44
+ description: "Request body as string or JSON object"
45
+ },
46
+ timeout: {
47
+ type: "number",
48
+ description: "Timeout in seconds for open/read"
49
+ }
50
+ },
51
+ required: ["intent", "method", "url"]
52
+ }
53
+ end
54
+ end
55
+
56
+ def run
57
+ http_method = arg(:method)
58
+ url = arg(:url)
59
+ headers = arg(:headers) || {}
60
+ query = arg(:query) || {}
61
+ body = arg(:body)
62
+ timeout = arg(:timeout)
63
+
64
+ uri = URI.parse(url)
65
+ uri.query = URI.encode_www_form(query) if query.is_a?(Hash) && !query.empty?
66
+
67
+ http = Net::HTTP.new(uri.host, uri.port)
68
+ http.use_ssl = (uri.scheme == "https")
69
+ http.open_timeout = timeout if timeout
70
+ http.read_timeout = timeout if timeout
71
+
72
+ request_class =
73
+ case http_method.to_s.upcase
74
+ when "GET" then Net::HTTP::Get
75
+ when "POST" then Net::HTTP::Post
76
+ when "PUT" then Net::HTTP::Put
77
+ when "PATCH" then Net::HTTP::Patch
78
+ when "DELETE" then Net::HTTP::Delete
79
+ when "HEAD" then Net::HTTP::Head
80
+ when "OPTIONS" then Net::HTTP::Options
81
+ else
82
+ return error_result(message: "Unsupported HTTP method: #{http_method}")
83
+ end
84
+
85
+ req = request_class.new(uri)
86
+ headers.each { |key, value| req[key] = value.to_s }
87
+
88
+ if body
89
+ if body.is_a?(Hash)
90
+ req.body = body.to_json
91
+ req["Content-Type"] ||= "application/json"
92
+ else
93
+ req.body = body.to_s
94
+ end
95
+ end
96
+
97
+ agent.status.verbose(" • #{http_method} #{url}")
98
+ response = http.request(req)
99
+ agent.status.verbose(" • Response: #{response.code}")
100
+ agent.logger.info("#{http_method} #{url} -> #{response.code}")
101
+
102
+ charset = response.type_params["charset"] || "UTF-8"
103
+ body = response.body.to_s.dup.force_encoding(charset)
104
+ body = body.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
105
+
106
+ success_result(status: response.code.to_i, headers: response.each_header.to_h, body: body)
107
+ rescue => error
108
+ error_result(message: error.message)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ class Tools
5
+ class ListDirectory < BaseTool
6
+ class << self
7
+ def name
8
+ "list_directory"
9
+ end
10
+
11
+ def description
12
+ "List the contents of a directory."
13
+ end
14
+
15
+ def read_only?
16
+ true
17
+ end
18
+
19
+ def parameters
20
+ {
21
+ type: "object",
22
+ properties: {
23
+ intent: {
24
+ type: "string",
25
+ description: "Brief summary of intent of the operation, meant to be used for context compaction and presentation to the user. Use active voice (e.g., 'Reading X to do Y')."
26
+ },
27
+ directory_path: {
28
+ type: "string",
29
+ description: "Path to the directory to list"
30
+ }
31
+ },
32
+ required: ["directory_path", "intent"]
33
+ }
34
+ end
35
+ end
36
+
37
+ def run
38
+ dir_path = arg(:directory_path)
39
+ if dir_path.nil? || dir_path.strip.empty?
40
+ return error_result(message: "`directory_path` was empty or missing")
41
+ end
42
+
43
+ agent.status.verbose(" • Listing directory contents: '#{dir_path}'")
44
+ expanded_path = ::File.expand_path(dir_path)
45
+ entries = Dir.children(expanded_path)
46
+
47
+ agent.status.verbose(" • Directory contents listed: #{entries.size} entries in '#{dir_path}'")
48
+ agent.logger.info("Listing directory contents: '#{dir_path}'")
49
+ success_result(entries)
50
+ rescue Errno::ENOENT
51
+ error_result(message: "Directory not found: '#{dir_path}'")
52
+ rescue Errno::EACCES
53
+ error_result(message: "Permission denied: #{dir_path}")
54
+ end
55
+ end
56
+ end
57
+ end