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,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
@@ -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