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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ class Completions
5
+ class FileNames
6
+ def self.complete(input:, agent:)
7
+ return [] unless input.start_with?("@")
8
+
9
+ prefix = input[1..]
10
+ pattern = if prefix.include?("/")
11
+ prefix.end_with?("/") ? "#{prefix}*" : File.join(File.dirname(prefix), "#{File.basename(prefix)}*")
12
+ else
13
+ "#{prefix}*"
14
+ end
15
+
16
+ Dir.glob(pattern).map do |path|
17
+ "@#{path}#{File.directory?(path) ? "/" : ""}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ class Completions
5
+ class SlashCommands
6
+ def self.complete(input:, agent:)
7
+ return [] unless input.start_with?("/")
8
+
9
+ prefix = input[1..] || ""
10
+ agent.commands.constants
11
+ .map(&:name)
12
+ .grep(/\A#{Regexp.escape(prefix)}/)
13
+ .map { |cmd| "/#{cmd}" }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ class Completions
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::Completions.constants(false).map do |name|
14
+ ::Zephira::Completions.const_get(name)
15
+ end
16
+ end
17
+
18
+ def complete_all(input:, agent:)
19
+ constants.flat_map { |completion| completion.complete(input: input, agent: agent) }.uniq
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Zephira
6
+ class Config
7
+ def self.read(key)
8
+ project_config_path = File.join(Dir.pwd, ".zephira.yml")
9
+ global_config_path = File.expand_path("~/.zephira.yml")
10
+
11
+ project_config = File.exist?(project_config_path) ? (YAML.load_file(project_config_path) || {})[key] : nil
12
+ global_config = File.exist?(global_config_path) ? (YAML.load_file(global_config_path) || {})[key] : nil
13
+
14
+ ENV[key] || project_config || global_config
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ class Formatter
5
+ FORMAT_STRINGS = {
6
+ color_white: "\e[37m",
7
+ color_red: "\e[31m",
8
+ color_dark_red: "\e[91m",
9
+ color_green: "\e[32m",
10
+ color_grey: "\e[90m",
11
+ format_bold: "\e[1m",
12
+ format_italic: "\e[3m",
13
+ format_underlined: "\e[4m",
14
+ format_strikethrough: "\e[9m",
15
+ format_bold_italic: "\e[1;3m",
16
+ format_bold_underlined: "\e[1;4m",
17
+ format_italic_underlined: "\e[3;4m",
18
+ format_bold_strikethrough: "\e[1;9m",
19
+ format_italic_strikethrough: "\e[3;9m",
20
+ format_underlined_strikethrough: "\e[4;9m",
21
+ format_clear: "\e[0m"
22
+ }.freeze
23
+
24
+ class << self
25
+ def format(string, indent: 0)
26
+ raise ArgumentError, "Indent must be a non-negative integer" unless indent.is_a?(Integer) && indent >= 0
27
+
28
+ string.each_line.map do |line|
29
+ indented_line = (" " * indent) + line.chomp
30
+
31
+ FORMAT_STRINGS.each do |key, value|
32
+ indented_line.gsub!("###{key.upcase}##", value)
33
+ end
34
+
35
+ # replace any remaining format strings with the clear format
36
+ # this avoids having malformed format strings left in the output
37
+ indented_line.gsub!(/##\w+##/, style(:clear))
38
+
39
+ indented_line
40
+ end.join("\n")
41
+ end
42
+
43
+ def available_formats
44
+ FORMAT_STRINGS.except(:format_clear).map do |key, value|
45
+ format_string = "###{key.to_s.upcase}##"
46
+ description = "#{key.to_s.split("_")[1..].join(" ")} text"
47
+ "#{format_string} for #{description}"
48
+ end + ["##FORMAT_CLEAR## to clear all formatting"]
49
+ end
50
+
51
+ def color(color, string = nil)
52
+ color_code = FORMAT_STRINGS[:"color_#{color}"]
53
+ raise ArgumentError, "Invalid color: #{color}" unless color_code
54
+
55
+ return color_code if string.nil?
56
+ "#{color_code}#{string}#{style(:clear)}"
57
+ end
58
+
59
+ def style(style, string = nil)
60
+ style_code = FORMAT_STRINGS[:"format_#{style}"]
61
+ raise ArgumentError, "Invalid style: #{style}" unless style_code
62
+
63
+ return style_code if string.nil?
64
+ "#{style_code}#{string}#{style(:clear)}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Zephira
8
+ class History
9
+ STORAGE_DIR = ".zephira"
10
+ STORAGE_FILE = "history.jsonl"
11
+ COMPACTION_CHUNK_SIZE = 10
12
+
13
+ attr_reader :messages, :session_start
14
+
15
+ def initialize(messages = [])
16
+ @storage_dir = File.join(Dir.pwd, STORAGE_DIR)
17
+ @storage_file = File.join(@storage_dir, STORAGE_FILE)
18
+
19
+ FileUtils.mkdir_p(@storage_dir)
20
+
21
+ if messages.empty? && File.file?(@storage_file) && File.size(@storage_file) > 0
22
+ @messages = load_from_disk
23
+ else
24
+ @messages = messages.dup
25
+ write_all_to_disk
26
+ end
27
+
28
+ @session_start = @messages.size
29
+ end
30
+
31
+ def append(role:, content:, tool_calls: nil, tool_call_id: nil)
32
+ entry = {
33
+ role: role,
34
+ content: content,
35
+ tool_calls: tool_calls,
36
+ tool_call_id: tool_call_id,
37
+ timestamp: Time.now.iso8601
38
+ }.compact
39
+ @messages << entry
40
+ persist_entry(entry)
41
+ end
42
+
43
+ def size
44
+ @messages.sum { |message| Tokens.estimate(message[:content]) }
45
+ end
46
+
47
+ def compact(response_model:, api_key:, agent:, token_limit: Float::INFINITY)
48
+ return unless size > token_limit
49
+
50
+ chunks = []
51
+ while size > token_limit && !@messages.empty?
52
+ chunks << @messages.shift(COMPACTION_CHUNK_SIZE)
53
+ end
54
+
55
+ chunks.each do |chunk|
56
+ conversation = chunk.map { |message| "#{message[:role]}: #{message[:content]}" }.join("\n")
57
+ summary = response_model.simple_inference(
58
+ api_key: api_key,
59
+ agent: agent,
60
+ messages: [{role: "user", content: "Summarize the following conversation:\n#{conversation}"}]
61
+ )
62
+
63
+ @messages.unshift(
64
+ role: "system",
65
+ content: "[Summary of #{chunk.size} messages]\n#{summary}",
66
+ timestamp: Time.now.iso8601
67
+ )
68
+ end
69
+
70
+ write_all_to_disk
71
+ end
72
+
73
+ def clear
74
+ @messages.clear
75
+ write_all_to_disk
76
+ end
77
+
78
+ def clear_session
79
+ return unless @session_start
80
+ @messages = @messages[0...@session_start]
81
+ write_all_to_disk
82
+ end
83
+
84
+ def compact_tool_messages!
85
+ @messages = @messages
86
+ .reject { |message| message[:role] == "tool" }
87
+ .map do |message|
88
+ next message unless message[:tool_calls]&.any?
89
+
90
+ summary_lines = message[:tool_calls].map do |tool_call|
91
+ name = tool_call.dig(:function, :name) || tool_call.dig("function", "name")
92
+ arguments = tool_call.dig(:function, :arguments) || tool_call.dig("function", "arguments")
93
+ arguments = JSON.parse(arguments, symbolize_names: true)
94
+ "- `#{name}` with intent `#{arguments[:intent]}`"
95
+ end
96
+ summary_lines.unshift("Agent used tool(s):\n")
97
+ {role: "assistant", content: summary_lines.join("\n"), timestamp: message[:timestamp]}
98
+ end
99
+
100
+ write_all_to_disk
101
+ end
102
+
103
+ private
104
+
105
+ def load_from_disk
106
+ File.readlines(@storage_file).map { |line| JSON.parse(line, symbolize_names: true) }
107
+ end
108
+
109
+ def persist_entry(entry)
110
+ File.open(@storage_file, "a") { |file| file.puts JSON.generate(entry) }
111
+ end
112
+
113
+ def write_all_to_disk
114
+ File.open(@storage_file, "w") { |file| @messages.each { |entry| file.puts JSON.generate(entry) } }
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Zephira
6
+ class Logger
7
+ LOG_LEVELS = %i[debug info warn error].freeze
8
+
9
+ attr_reader :logfile, :log_level
10
+
11
+ def initialize(file_path:, log_level: :debug)
12
+ FileUtils.mkdir_p(File.dirname(file_path))
13
+ @logfile = File.open(file_path, "a")
14
+ @log_level = log_level
15
+
16
+ raise ArgumentError, "Invalid log level: #{log_level}" unless LOG_LEVELS.include?(log_level)
17
+ end
18
+
19
+ def debug(message, **args)
20
+ log(:debug, message, **args)
21
+ end
22
+
23
+ def info(message, **args)
24
+ log(:info, message, **args)
25
+ end
26
+
27
+ def error(message, **args)
28
+ log(:error, message, **args)
29
+ end
30
+
31
+ def warn(message, **args)
32
+ log(:warn, message, **args)
33
+ end
34
+
35
+ def log(level, message, **args)
36
+ return unless should_log?(level)
37
+
38
+ @logfile.puts "#{Time.now} - #{level.to_s.strip.upcase} - #{message} - #{args.inspect}"
39
+ @logfile.flush
40
+ end
41
+
42
+ def should_log?(level)
43
+ LOG_LEVELS.index(level) >= LOG_LEVELS.index(@log_level)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Zephira
6
+ module Models
7
+ # Base class for all model definitions.
8
+ #
9
+ # To add a new model:
10
+ # 1. Drop a new file in `lib/zephira/models/<name>.rb` — it is auto-loaded.
11
+ # 2. Subclass `BaseModel` and implement `model_name` and `context_limit`.
12
+ # 3. Optionally override `backend` to point at a specific backend class.
13
+ # Defaults to `Backends::OpenAiCompatible` (works for any provider with an
14
+ # OpenAI-compatible API). For provider-specific quirks (Mistral, Anthropic
15
+ # tool-call shape, etc.) define a dedicated backend class and return it
16
+ # from `backend`.
17
+ #
18
+ # `ENV["ZEPHIRA_BACKEND"]` overrides per-model `backend` for debugging.
19
+ class BaseModel
20
+ def self.model_name
21
+ raise NotImplementedError, "You must implement the model_name method"
22
+ end
23
+
24
+ def self.context_limit
25
+ raise NotImplementedError, "You must implement the context_limit method"
26
+ end
27
+
28
+ # Override in subclasses to bind a model to a specific backend class.
29
+ def self.backend
30
+ Zephira::Backends::OpenAiCompatible
31
+ end
32
+
33
+ def self.backend_class
34
+ identifier = ENV["ZEPHIRA_BACKEND"]
35
+ if identifier
36
+ found = Zephira::Backends.find_by_name(identifier)
37
+ return found if found
38
+ end
39
+ backend
40
+ end
41
+
42
+ def self.format_tools(tools)
43
+ tools.to_h.map do |tool|
44
+ {
45
+ type: "function",
46
+ function: {
47
+ name: tool[:name],
48
+ description: tool[:description],
49
+ parameters: tool[:parameters]
50
+ }
51
+ }
52
+ end
53
+ end
54
+
55
+ def self.inference(api_key:, agent:, messages: [], base_url: nil)
56
+ client = backend_class.new(api_key: api_key, base_url: base_url)
57
+
58
+ loop do
59
+ agent.thinking(self)
60
+ response = client.chat(
61
+ model_name: model_name,
62
+ messages: messages,
63
+ agent: agent,
64
+ tools: format_tools(agent.tools)
65
+ )
66
+
67
+ tool_calls = Array(response["tool_calls"]).select { |tool_call| tool_call["type"] == "function" }
68
+
69
+ if tool_calls.empty?
70
+ content = response["content"]
71
+ return (content.nil? || content.empty?) ? nil : content
72
+ end
73
+
74
+ messages << {role: "assistant", content: response["content"], tool_calls: response["tool_calls"]}
75
+ agent.history.append(role: "assistant", content: response["content"], tool_calls: response["tool_calls"])
76
+
77
+ dispatch_tool_calls(tool_calls, agent: agent).each do |call, content|
78
+ messages << {role: "tool", tool_call_id: call["id"], content: content}
79
+ agent.history.append(role: "tool", tool_call_id: call["id"], content: content)
80
+ end
81
+ end
82
+ end
83
+
84
+ # Returns an array of [call, content] pairs in the original order. Read-only
85
+ # tools are run concurrently via threads (network/disk I/O releases the GVL);
86
+ # mutating tools run sequentially after, in original order.
87
+ def self.dispatch_tool_calls(tool_calls, agent:)
88
+ results = Array.new(tool_calls.size)
89
+
90
+ read_only_calls = []
91
+ mutating_calls = []
92
+ tool_calls.each_with_index do |call, index|
93
+ if agent.tools.read_only?(call["function"]["name"])
94
+ read_only_calls << [index, call]
95
+ else
96
+ mutating_calls << [index, call]
97
+ end
98
+ end
99
+
100
+ threads = read_only_calls.map do |index, call|
101
+ Thread.new do
102
+ args = parse_tool_arguments(call, agent: agent)
103
+ result = agent.run_tool(name: call["function"]["name"], args: args)
104
+ results[index] = [call, serialize_tool_result(result)]
105
+ end
106
+ end
107
+ threads.each(&:join)
108
+
109
+ mutating_calls.each do |index, call|
110
+ args = parse_tool_arguments(call, agent: agent)
111
+ result = agent.run_tool(name: call["function"]["name"], args: args)
112
+ results[index] = [call, serialize_tool_result(result)]
113
+ end
114
+
115
+ results
116
+ end
117
+
118
+ def self.simple_inference(api_key:, messages:, agent: nil, base_url: nil)
119
+ client = backend_class.new(api_key: api_key, base_url: base_url)
120
+ agent.thinking(self) if agent.respond_to?(:thinking)
121
+ client.chat(model_name: model_name, messages: messages, agent: agent)["content"]
122
+ end
123
+
124
+ def self.parse_tool_arguments(call, agent:)
125
+ raw = call["function"]["arguments"] || "{}"
126
+ JSON.parse(raw, symbolize_names: true)
127
+ rescue JSON::ParserError => exception
128
+ agent&.logger&.error("Failed to parse tool arguments for #{call["function"]["name"]}: #{exception.message}. Raw: #{raw.inspect}")
129
+ {}
130
+ end
131
+
132
+ def self.serialize_tool_result(result)
133
+ return result unless result.is_a?(Hash) && result.key?(:outcome)
134
+
135
+ if result[:outcome] == "success"
136
+ result[:data].is_a?(String) ? result[:data] : JSON.pretty_generate([result[:data]])
137
+ else
138
+ result[:error].to_s
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ class ChatGpt41 < BaseModel
6
+ def self.model_name
7
+ "gpt-4.1"
8
+ end
9
+
10
+ def self.context_limit
11
+ 1_047_576
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ class ChatGpt41Mini < BaseModel
6
+ def self.model_name
7
+ "gpt-4.1-mini"
8
+ end
9
+
10
+ def self.context_limit
11
+ 1_047_576
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ class Claude35Sonnet < BaseModel
6
+ def self.model_name
7
+ "claude-3-5-sonnet-20241022"
8
+ end
9
+
10
+ def self.context_limit
11
+ 200_000
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ class Gpt54 < BaseModel
6
+ def self.model_name
7
+ "gpt-5.4"
8
+ end
9
+
10
+ def self.context_limit
11
+ 1_047_576
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ class Gpt55 < BaseModel
6
+ def self.model_name
7
+ "gpt-5.5"
8
+ end
9
+
10
+ def self.context_limit
11
+ 1_047_576
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ class GptO4Mini < BaseModel
6
+ def self.model_name
7
+ "o4-mini"
8
+ end
9
+
10
+ def self.context_limit
11
+ 200_000
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ class Llama4 < BaseModel
6
+ def self.model_name
7
+ "meta-llama/llama-4-maverick-17b-128e-instruct"
8
+ end
9
+
10
+ def self.context_limit
11
+ 131_072
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ module Models
5
+ require_relative "models/base_model"
6
+ Dir[File.join(__dir__, "models", "*.rb")].each { |file| require file }
7
+
8
+ def self.available
9
+ constants(false)
10
+ .map { |const| const_get(const) }
11
+ .reject { |const| const == BaseModel }
12
+ .select { |const| const.respond_to?(:model_name) }
13
+ end
14
+
15
+ def self.find_by_name(name)
16
+ available.find { |model| model.model_name.casecmp(name.to_s).zero? }
17
+ end
18
+ end
19
+ end