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,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "readline"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tty-spinner"
|
|
6
|
+
require "tty-screen"
|
|
7
|
+
require "tty-cursor"
|
|
8
|
+
|
|
9
|
+
module Zephira
|
|
10
|
+
class Agent
|
|
11
|
+
COMPACTION_TRIGGER_RATIO = 0.8
|
|
12
|
+
COMPACTION_TARGET_RATIO = 0.5
|
|
13
|
+
|
|
14
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
15
|
+
You are a helpful command line agent called Zephira.
|
|
16
|
+
You can run commands and tools to assist the user.
|
|
17
|
+
You should make full use of the tools that are available to you.
|
|
18
|
+
You can use the knowledge you already have to help the user, but if you don't know something, you should:
|
|
19
|
+
- Use the tools available to you to find the answer
|
|
20
|
+
- Ask the user for more information
|
|
21
|
+
- Do not make up answers or pretend to know something you don't.
|
|
22
|
+
|
|
23
|
+
Return all responses in a format that is easy to read in a terminal:
|
|
24
|
+
- Do NOT return responses in Markdown format.
|
|
25
|
+
- You can use unicode characters to make your output more readable.
|
|
26
|
+
- You can use emojis to make your output more engaging (but don't overdo it).
|
|
27
|
+
- You can use colors to highlight important information.
|
|
28
|
+
- You can use formatting to make your output more readable.
|
|
29
|
+
- You can return links to documentation or other resources as full URLs.
|
|
30
|
+
|
|
31
|
+
You can use the following formatting tokens in your responses:
|
|
32
|
+
- #{Formatter.available_formats.join("\n - ")}
|
|
33
|
+
|
|
34
|
+
If you are trying to perform operations that don't seem to be working,
|
|
35
|
+
you should stop what you're doing and tell the user that you are unable
|
|
36
|
+
to perform the operation, and tell the user why.
|
|
37
|
+
|
|
38
|
+
When updating a file using the `update_file` tool, always output the complete file content — never partial content or diffs.
|
|
39
|
+
|
|
40
|
+
You should not try to guess what the user is trying to do, or try to
|
|
41
|
+
perform operations that are not explicitly requested by the user.
|
|
42
|
+
|
|
43
|
+
Additional instructions provided by the user. The project-local instructions
|
|
44
|
+
should overrule the global instructions:
|
|
45
|
+
|
|
46
|
+
Global instructions (loaded from ~/.zephira/additional_instructions.md):
|
|
47
|
+
@@@GLOBAL_ADDITIONAL_INSTRUCTIONS@@@
|
|
48
|
+
|
|
49
|
+
Project instructions (loaded from ./.zephira/additional_instructions.md):
|
|
50
|
+
@@@PROJECT_ADDITIONAL_INSTRUCTIONS@@@
|
|
51
|
+
|
|
52
|
+
The user's current `date` is: @@@DATE@@@
|
|
53
|
+
The user's current `uname -a` is: @@@UNAME@@@
|
|
54
|
+
The user's current `pwd` is: @@@PWD@@@
|
|
55
|
+
The user's current `ls -R` is: @@@LSR@@@
|
|
56
|
+
PROMPT
|
|
57
|
+
|
|
58
|
+
LOGO = <<~'LOGO'
|
|
59
|
+
░▒▓████████▓▒░░▒▓████████▓▒░░▒▓███████▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓███████▓▒░ ░▒▓██████▓▒░
|
|
60
|
+
░▒▓██▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░
|
|
61
|
+
░▒▓██▓▒░ ░▒▓██████▓▒░ ░▒▓███████▓▒░ ░▒▓████████▓▒░░▒▓█▓▒░░▒▓███████▓▒░ ░▒▓████████▓▒░
|
|
62
|
+
░▒▓██▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░
|
|
63
|
+
░▒▓████████▓▒░░▒▓████████▓▒░░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░
|
|
64
|
+
LOGO
|
|
65
|
+
|
|
66
|
+
attr_reader :history, :tools, :commands, :completions, :logger, :status
|
|
67
|
+
attr_accessor :model, :verbose
|
|
68
|
+
|
|
69
|
+
def initialize
|
|
70
|
+
@verbose = false
|
|
71
|
+
log_file_path = File.join(Dir.pwd, ".zephira", "session.log")
|
|
72
|
+
@logger = Logger.new(file_path: log_file_path)
|
|
73
|
+
@status = Status.new(self)
|
|
74
|
+
@spinner = nil
|
|
75
|
+
@output_mutex = Mutex.new
|
|
76
|
+
|
|
77
|
+
tool_dirs = [File.expand_path("tools", __dir__)]
|
|
78
|
+
command_dirs = [File.expand_path("commands", __dir__)]
|
|
79
|
+
completion_dirs = [File.expand_path("completions", __dir__)]
|
|
80
|
+
|
|
81
|
+
@tools = Tools.load(paths: tool_dirs)
|
|
82
|
+
@commands = Commands.load(paths: command_dirs)
|
|
83
|
+
@completions = Completions.load(paths: completion_dirs)
|
|
84
|
+
@history = History.new
|
|
85
|
+
@history.compact_tool_messages!
|
|
86
|
+
|
|
87
|
+
@uname = `uname -a`.strip
|
|
88
|
+
@pwd = `pwd`.strip
|
|
89
|
+
|
|
90
|
+
@model = resolve_model
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def thinking(model_class)
|
|
94
|
+
thinkmojis = %w[🤔 🧠 💭 🤯 🧐 ⏳ 🔄 🌀 🤨 💡 🧩 🔍 📚 ⚙️]
|
|
95
|
+
token_count = Tokens.estimate(history.messages.to_json)
|
|
96
|
+
update_status("Thinking... #{thinkmojis.shuffle.first} " + Formatter.color(:grey, "(#{model_class.model_name} - #{token_count} tokens)"))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def update_status(msg)
|
|
100
|
+
@output_mutex.synchronize do
|
|
101
|
+
@spinner&.spin
|
|
102
|
+
puts msg
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def run_tool(name:, args:)
|
|
107
|
+
@tools.run(name: name, args: args, agent: self)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_command(name:, args:)
|
|
111
|
+
@commands.run(name: name, args: args, agent: self)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def compact_history(force: false)
|
|
115
|
+
current = Tokens.estimate(JSON.dump(@history.messages))
|
|
116
|
+
threshold = (@model.context_limit * COMPACTION_TRIGGER_RATIO).to_i
|
|
117
|
+
target = force ? [current / 2, 1].max : (@model.context_limit * COMPACTION_TARGET_RATIO).to_i
|
|
118
|
+
|
|
119
|
+
return false if @history.messages.empty?
|
|
120
|
+
return false if !force && current <= threshold
|
|
121
|
+
|
|
122
|
+
puts Formatter.color(:grey, " ✦ Compacting history (~#{current} tokens)...")
|
|
123
|
+
@history.compact(
|
|
124
|
+
response_model: @model,
|
|
125
|
+
api_key: Config.read("ZEPHIRA_API_KEY"),
|
|
126
|
+
agent: self,
|
|
127
|
+
token_limit: target
|
|
128
|
+
)
|
|
129
|
+
after = Tokens.estimate(JSON.dump(@history.messages))
|
|
130
|
+
puts Formatter.color(:grey, " ✦ History compacted (~#{after} tokens).")
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def compact_if_needed
|
|
135
|
+
compact_history(force: false)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def run_loop
|
|
139
|
+
Readline.completion_proc = proc { |input| @completions.complete_all(input: input, agent: self) }
|
|
140
|
+
seed_readline_history
|
|
141
|
+
print_intro
|
|
142
|
+
|
|
143
|
+
loop do
|
|
144
|
+
render_status_bar
|
|
145
|
+
|
|
146
|
+
user_input = Readline.readline("> ", true)
|
|
147
|
+
break if user_input.nil?
|
|
148
|
+
|
|
149
|
+
input = user_input.strip
|
|
150
|
+
next if input.empty?
|
|
151
|
+
|
|
152
|
+
TTY::Cursor.hide
|
|
153
|
+
echo_user_input(input)
|
|
154
|
+
|
|
155
|
+
if input.start_with?("/")
|
|
156
|
+
dispatch_command(input)
|
|
157
|
+
TTY::Cursor.show
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
process_user_message(input)
|
|
162
|
+
TTY::Cursor.show
|
|
163
|
+
|
|
164
|
+
history.compact_tool_messages!
|
|
165
|
+
compact_if_needed
|
|
166
|
+
rescue Interrupt
|
|
167
|
+
puts "\n[Interrupted]"
|
|
168
|
+
break
|
|
169
|
+
rescue => e
|
|
170
|
+
puts "\nError: #{e.message}"
|
|
171
|
+
logger.error("#{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def seed_readline_history
|
|
176
|
+
return unless history.session_start > 0
|
|
177
|
+
history.messages[0...history.session_start]
|
|
178
|
+
.select { |message| message[:role] == "user" }
|
|
179
|
+
.map { |message| message[:content] }
|
|
180
|
+
.each { |command| Readline::HISTORY.push(command) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def print_intro
|
|
184
|
+
logo_width = LOGO.each_line.first.chomp.length
|
|
185
|
+
logo_indent = [(screen_width - logo_width) / 2, 0].max
|
|
186
|
+
puts Formatter.format(Formatter.color(:green, LOGO), indent: logo_indent)
|
|
187
|
+
puts
|
|
188
|
+
puts "#{Formatter.color(:grey, "System:")}\n Zephira starting... #{Formatter.color(:green, "Ready!")}"
|
|
189
|
+
puts
|
|
190
|
+
puts Formatter.color(:grey, "Zephira:")
|
|
191
|
+
puts " Hello! I am Zephira, your command line assistant. How can I help you today?"
|
|
192
|
+
puts " Type your command or question below. If you're not sure what to ask, you can"
|
|
193
|
+
puts " ask me what I can do for you... or type '/help' for a list of commands."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_status_bar
|
|
197
|
+
context_used = Tokens.estimate(JSON.dump(@history.messages))
|
|
198
|
+
context_limit = @model.context_limit
|
|
199
|
+
# Percent of context window REMAINING (limit - used) / limit, clamped to 0..100.
|
|
200
|
+
context_pct = ((context_limit - context_used).to_f / context_limit * 100).clamp(0, 100).to_i
|
|
201
|
+
width = screen_width
|
|
202
|
+
print TTY::Cursor.move_to(0, screen_height - 3)
|
|
203
|
+
puts Formatter.color(:grey, "-" * width)
|
|
204
|
+
|
|
205
|
+
sandbox_label = ENV["ZEPHIRA_IN_SANDBOX"] == "1" ? "sandboxed" : "⚠ DANGER: NO SANDBOX"
|
|
206
|
+
sandbox_color = ENV["ZEPHIRA_IN_SANDBOX"] == "1" ? :green : :red
|
|
207
|
+
right_text = "ctrl+c to exit | '/help' + enter to see commands | #{context_pct}% context left"
|
|
208
|
+
padding = [width - sandbox_label.length - right_text.length, 1].max
|
|
209
|
+
puts Formatter.color(sandbox_color, sandbox_label) + " " * padding + Formatter.color(:grey, right_text)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def echo_user_input(input)
|
|
213
|
+
rows = screen_rows
|
|
214
|
+
puts
|
|
215
|
+
puts Formatter.color(:grey, "=" * screen_width)
|
|
216
|
+
puts "\n" * rows
|
|
217
|
+
print TTY::Cursor.up(rows)
|
|
218
|
+
puts Formatter.color(:grey, "User:")
|
|
219
|
+
puts Formatter.format(input, indent: 2)
|
|
220
|
+
puts
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def dispatch_command(input)
|
|
224
|
+
parts = input[1..].strip.split
|
|
225
|
+
run_command(name: parts.first, args: parts[1..] || [])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def process_user_message(input)
|
|
229
|
+
history.append(role: "user", content: input)
|
|
230
|
+
messages = [system_prompt] + history.messages.map { |message| message.slice(:role, :content, :tool_call_id, :tool_calls) }
|
|
231
|
+
|
|
232
|
+
response = run_inference_with_spinner(messages)
|
|
233
|
+
|
|
234
|
+
if response
|
|
235
|
+
history.append(role: "assistant", content: response)
|
|
236
|
+
puts Formatter.color(:grey, "\nZephira:")
|
|
237
|
+
puts Formatter.format(response, indent: 2)
|
|
238
|
+
puts
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def run_inference_with_spinner(messages)
|
|
243
|
+
response = nil
|
|
244
|
+
spinner_format_string = Formatter.color(:grey, "[") + Formatter.color(:green, " :spinner ") + Formatter.color(:grey, ":elapsed] ")
|
|
245
|
+
@spinner = TTY::Spinner.new(spinner_format_string, format: :dots)
|
|
246
|
+
spinner_started_at = Time.now
|
|
247
|
+
@spinner.on(:spin) do
|
|
248
|
+
elapsed = (Time.now - spinner_started_at).to_i
|
|
249
|
+
@spinner.update(elapsed: sprintf("%03ds", elapsed))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
@spinner.run(Formatter.color(:green, "Done!")) do
|
|
253
|
+
response = @model.inference(
|
|
254
|
+
api_key: Config.read("ZEPHIRA_API_KEY"),
|
|
255
|
+
base_url: Config.read("ZEPHIRA_BASE_URL"),
|
|
256
|
+
messages: messages,
|
|
257
|
+
agent: self
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
@spinner = nil
|
|
261
|
+
response
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def load_additional_instructions
|
|
267
|
+
instructions = {}
|
|
268
|
+
global_file = File.join(Dir.home, ".zephira", "additional_instructions.md")
|
|
269
|
+
project_file = File.join(Dir.pwd, ".zephira", "additional_instructions.md")
|
|
270
|
+
instructions[:global] = File.read(global_file).strip if File.exist?(global_file)
|
|
271
|
+
instructions[:project] = File.read(project_file).strip if File.exist?(project_file)
|
|
272
|
+
instructions
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def system_prompt
|
|
276
|
+
instructions = load_additional_instructions
|
|
277
|
+
{
|
|
278
|
+
role: "system",
|
|
279
|
+
content: SYSTEM_PROMPT
|
|
280
|
+
.gsub("@@@GLOBAL_ADDITIONAL_INSTRUCTIONS@@@", instructions[:global] || "[NONE FOUND]")
|
|
281
|
+
.gsub("@@@PROJECT_ADDITIONAL_INSTRUCTIONS@@@", instructions[:project] || "[NONE FOUND]")
|
|
282
|
+
.gsub("@@@DATE@@@", `date`.strip)
|
|
283
|
+
.gsub("@@@UNAME@@@", @uname)
|
|
284
|
+
.gsub("@@@PWD@@@", @pwd)
|
|
285
|
+
.gsub("@@@LSR@@@", `ls -R`.strip)
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def resolve_model
|
|
290
|
+
name = Config.read("ZEPHIRA_MODEL") || "gpt-5.4"
|
|
291
|
+
Models.find_by_name(name) || Models::Gpt54
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def screen_width
|
|
295
|
+
TTY::Screen.width
|
|
296
|
+
rescue
|
|
297
|
+
80
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def screen_height
|
|
301
|
+
TTY::Screen.height
|
|
302
|
+
rescue
|
|
303
|
+
24
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def screen_rows
|
|
307
|
+
TTY::Screen.rows
|
|
308
|
+
rescue
|
|
309
|
+
24
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Zephira
|
|
9
|
+
module Backends
|
|
10
|
+
class OpenAiCompatible
|
|
11
|
+
def self.name
|
|
12
|
+
"openai_compatible"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
REQUEST_TIMEOUT = 300
|
|
16
|
+
|
|
17
|
+
def initialize(api_key:, base_url: nil)
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
@base_url = base_url || "https://api.openai.com/v1"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chat(model_name:, messages:, agent:, tools: nil, options: {})
|
|
23
|
+
payload = {
|
|
24
|
+
model: model_name,
|
|
25
|
+
messages: messages,
|
|
26
|
+
tools: tools
|
|
27
|
+
}.merge(options).compact
|
|
28
|
+
|
|
29
|
+
debug_log(payload, agent: agent) if Config.read("ZEPHIRA_DEBUG") == "true"
|
|
30
|
+
|
|
31
|
+
client = Faraday.new(
|
|
32
|
+
url: @base_url,
|
|
33
|
+
headers: {
|
|
34
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
35
|
+
"Content-Type" => "application/json"
|
|
36
|
+
},
|
|
37
|
+
request: {timeout: REQUEST_TIMEOUT}
|
|
38
|
+
) do |faraday|
|
|
39
|
+
faraday.request :json
|
|
40
|
+
faraday.response :raise_error
|
|
41
|
+
faraday.adapter Faraday.default_adapter
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
response = client.post("chat/completions", JSON.generate(payload))
|
|
45
|
+
raw = JSON.parse(response.body)
|
|
46
|
+
|
|
47
|
+
agent&.logger&.info "OpenAI API response: #{raw.inspect}"
|
|
48
|
+
raw.dig("choices", 0, "message") || {}
|
|
49
|
+
rescue => exception
|
|
50
|
+
agent&.logger&.error "OpenAI API request failed: #{exception.class}: #{exception.message}"
|
|
51
|
+
if exception.respond_to?(:response) && exception.response
|
|
52
|
+
agent&.logger&.error "Response status: #{exception.response[:status]}"
|
|
53
|
+
agent&.logger&.error "Response body: #{exception.response[:body]}"
|
|
54
|
+
end
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def debug_log(parameters, agent:)
|
|
61
|
+
log_dir = File.join(Dir.pwd, ".zephira", "logs")
|
|
62
|
+
FileUtils.mkdir_p(log_dir)
|
|
63
|
+
timestamp = Time.now.utc.strftime("%Y%m%dT%H%M%S%L")
|
|
64
|
+
filepath = File.join(log_dir, "#{timestamp}_openai_request.log")
|
|
65
|
+
File.write(filepath, JSON.pretty_generate({
|
|
66
|
+
timestamp: Time.now.utc.iso8601,
|
|
67
|
+
base_url: @base_url,
|
|
68
|
+
parameters: parameters
|
|
69
|
+
}))
|
|
70
|
+
agent&.logger&.debug "OpenAI request logged to: #{filepath}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
module Backends
|
|
5
|
+
Dir[File.join(__dir__, "backends", "*.rb")].each { |file| require file }
|
|
6
|
+
|
|
7
|
+
def self.available
|
|
8
|
+
constants(false)
|
|
9
|
+
.map { |const| const_get(const) }
|
|
10
|
+
.select { |const| const.respond_to?(:name) && const.name.is_a?(String) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.find_by_name(identifier)
|
|
14
|
+
available.find { |backend| backend.name == identifier }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/zephira/cli.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Zephira
|
|
6
|
+
class CLI
|
|
7
|
+
DANGEROUS_SKIP_SANDBOX_FLAG = "--dangerously-skip-sandbox"
|
|
8
|
+
|
|
9
|
+
def initialize(argv)
|
|
10
|
+
ENV["ZEPHIRA_SANDBOX"] = "false" if argv.include?(DANGEROUS_SKIP_SANDBOX_FLAG)
|
|
11
|
+
Zephira::Sandbox.exec_if_needed!(argv)
|
|
12
|
+
option_parser.parse!(argv)
|
|
13
|
+
Zephira::Agent.new.run_loop
|
|
14
|
+
rescue OptionParser::InvalidOption
|
|
15
|
+
puts option_parser
|
|
16
|
+
exit(1)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def option_parser
|
|
22
|
+
OptionParser.new do |opts|
|
|
23
|
+
opts.banner = "Usage: zephira [options]"
|
|
24
|
+
|
|
25
|
+
opts.on("-v", "--version", "Print the version") do
|
|
26
|
+
puts Zephira::VERSION
|
|
27
|
+
exit(0)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
opts.on("-h", "--help", "Print this help") do
|
|
31
|
+
puts opts
|
|
32
|
+
exit(0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
opts.on(DANGEROUS_SKIP_SANDBOX_FLAG, "Skip sandbox and run without isolation") do
|
|
36
|
+
ENV["ZEPHIRA_SANDBOX"] = "false"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
class About
|
|
6
|
+
class << self
|
|
7
|
+
def name
|
|
8
|
+
"about"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Display information about the agent"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(agent:, args:)
|
|
16
|
+
puts [
|
|
17
|
+
"Zephira: A toy coding agent",
|
|
18
|
+
" Version: #{::Zephira::VERSION}",
|
|
19
|
+
" https://github.com/aarongough/zephira",
|
|
20
|
+
"",
|
|
21
|
+
"Released under the MIT license."
|
|
22
|
+
].join("\n")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
class Bye
|
|
6
|
+
class << self
|
|
7
|
+
def name
|
|
8
|
+
"bye"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"End the session and close the agent"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(agent:, args:)
|
|
16
|
+
puts "Bye!"
|
|
17
|
+
exit(0)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
class Clear
|
|
6
|
+
USAGE = "Usage: /clear [session|all]"
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def name
|
|
10
|
+
"clear"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def description
|
|
14
|
+
"Clear history: 'session' clears current session, 'all' clears everything"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(agent:, args:)
|
|
18
|
+
case args&.first
|
|
19
|
+
when "session"
|
|
20
|
+
agent.history.clear_session
|
|
21
|
+
puts "Session history cleared."
|
|
22
|
+
when "all"
|
|
23
|
+
agent.history.clear
|
|
24
|
+
puts "History cleared."
|
|
25
|
+
else
|
|
26
|
+
puts USAGE
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
class Compact
|
|
6
|
+
class << self
|
|
7
|
+
def name
|
|
8
|
+
"compact"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Summarize older history to free up context"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(agent:, args:)
|
|
16
|
+
compacted = agent.compact_history(force: true)
|
|
17
|
+
puts "Nothing to compact." unless compacted
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
class Help
|
|
6
|
+
class << self
|
|
7
|
+
def name
|
|
8
|
+
"help"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Display this help information"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(agent:, args:)
|
|
16
|
+
lines = agent.commands.constants.map { |cmd| " /#{cmd.name}: #{cmd.description}" }
|
|
17
|
+
puts "Available commands:\n#{lines.join("\n")}\n\n"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
class History
|
|
6
|
+
class << self
|
|
7
|
+
def name
|
|
8
|
+
"history"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Display the conversation history"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(agent:, args:)
|
|
16
|
+
agent.history.messages.each do |message|
|
|
17
|
+
content = message[:content].to_s
|
|
18
|
+
content = "#{content.gsub("\n", "\\n").slice(0, 100)}..." if content.length > 100
|
|
19
|
+
puts "[#{message[:timestamp]}] #{message[:role]}: #{content}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
class Model
|
|
6
|
+
class << self
|
|
7
|
+
def name
|
|
8
|
+
"model"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"List available models or switch: /model set MODEL_NAME"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run(agent:, args:)
|
|
16
|
+
model_classes = Zephira::Models.available
|
|
17
|
+
|
|
18
|
+
if args.nil? || args.empty?
|
|
19
|
+
puts "Available models:"
|
|
20
|
+
model_classes.each do |model|
|
|
21
|
+
marker = (model == agent.model) ? "*" : " "
|
|
22
|
+
suffix = (model == agent.model) ? " (current)" : ""
|
|
23
|
+
puts " #{marker} #{model.model_name}#{suffix}"
|
|
24
|
+
end
|
|
25
|
+
puts
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
parts = args.dup
|
|
30
|
+
parts.shift if parts.first.to_s.casecmp("set").zero?
|
|
31
|
+
model_name = parts.first
|
|
32
|
+
|
|
33
|
+
if model_name.nil? || model_name.strip.empty?
|
|
34
|
+
puts "Usage: /model set MODEL_NAME"
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
target = Zephira::Models.find_by_name(model_name)
|
|
39
|
+
|
|
40
|
+
if target
|
|
41
|
+
agent.model = target
|
|
42
|
+
puts "Model changed to #{target.model_name}"
|
|
43
|
+
else
|
|
44
|
+
puts "Unknown model '#{model_name}'. Available models:"
|
|
45
|
+
model_classes.each { |model| puts " #{model.model_name}" }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zephira
|
|
4
|
+
class Commands
|
|
5
|
+
def self.load(paths:)
|
|
6
|
+
paths.each do |path|
|
|
7
|
+
Dir.glob(File.join(path, "**", "*.rb")).each { |file| require file }
|
|
8
|
+
end
|
|
9
|
+
new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def constants
|
|
13
|
+
@constants ||= ::Zephira::Commands.constants(false).map do |name|
|
|
14
|
+
::Zephira::Commands.const_get(name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(name:, args:, agent:)
|
|
19
|
+
command_class = constants.find { |command| command.name == name }
|
|
20
|
+
if command_class.nil?
|
|
21
|
+
puts "Unknown command '/#{name}'. Type /help for a list of commands."
|
|
22
|
+
else
|
|
23
|
+
command_class.run(agent: agent, args: args)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
constants.map { |command| {name: command.name, description: command.description} }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|