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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +36 -0
- data/Dockerfile +39 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +145 -0
- data/README.md +230 -0
- data/Rakefile +12 -0
- data/exe/zephira +6 -0
- data/lib/zephira/agent/status.rb +20 -0
- data/lib/zephira/agent.rb +312 -0
- data/lib/zephira/backends/open_ai_compatible.rb +74 -0
- data/lib/zephira/backends.rb +17 -0
- data/lib/zephira/cli.rb +41 -0
- data/lib/zephira/commands/about.rb +27 -0
- data/lib/zephira/commands/bye.rb +22 -0
- data/lib/zephira/commands/clear.rb +32 -0
- data/lib/zephira/commands/compact.rb +22 -0
- data/lib/zephira/commands/help.rb +22 -0
- data/lib/zephira/commands/history.rb +25 -0
- data/lib/zephira/commands/model.rb +51 -0
- data/lib/zephira/commands.rb +31 -0
- data/lib/zephira/completions/file_names.rb +22 -0
- data/lib/zephira/completions/slash_commands.rb +17 -0
- data/lib/zephira/completions.rb +22 -0
- data/lib/zephira/config.rb +17 -0
- data/lib/zephira/formatter.rb +68 -0
- data/lib/zephira/history.rb +117 -0
- data/lib/zephira/logger.rb +46 -0
- data/lib/zephira/models/base_model.rb +143 -0
- data/lib/zephira/models/chat_gpt41.rb +15 -0
- data/lib/zephira/models/chat_gpt41_mini.rb +15 -0
- data/lib/zephira/models/claude_35_sonnet.rb +15 -0
- data/lib/zephira/models/gpt_5_4.rb +15 -0
- data/lib/zephira/models/gpt_5_5.rb +15 -0
- data/lib/zephira/models/gpt_o4_mini.rb +15 -0
- data/lib/zephira/models/llama4.rb +15 -0
- data/lib/zephira/models.rb +19 -0
- data/lib/zephira/sandbox.rb +193 -0
- data/lib/zephira/tokens.rb +16 -0
- data/lib/zephira/tools/base_tool.rb +105 -0
- data/lib/zephira/tools/code_search.rb +135 -0
- data/lib/zephira/tools/delete_file.rb +47 -0
- data/lib/zephira/tools/http_request.rb +112 -0
- data/lib/zephira/tools/list_directory.rb +57 -0
- data/lib/zephira/tools/memory_delete.rb +39 -0
- data/lib/zephira/tools/memory_list.rb +37 -0
- data/lib/zephira/tools/memory_read.rb +43 -0
- data/lib/zephira/tools/memory_store.rb +51 -0
- data/lib/zephira/tools/memory_write.rb +39 -0
- data/lib/zephira/tools/read_file.rb +90 -0
- data/lib/zephira/tools/shell.rb +82 -0
- data/lib/zephira/tools/update_file.rb +46 -0
- data/lib/zephira/tools/web_search.rb +113 -0
- data/lib/zephira/tools.rb +70 -0
- data/lib/zephira/version.rb +5 -0
- data/lib/zephira.rb +24 -0
- data/license.txt +21 -0
- data/standard.yml +3 -0
- 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
|