elelem 0.8.0 → 0.9.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.
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Net
5
+ class Claude
6
+ def self.anthropic(model:, api_key:, http: Elelem::Net.http)
7
+ new(
8
+ endpoint: "https://api.anthropic.com/v1/messages",
9
+ headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
10
+ model:,
11
+ http:
12
+ )
13
+ end
14
+
15
+ def self.vertex(model:, project:, region: "us-east5", http: Elelem::Net.http)
16
+ new(
17
+ endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
18
+ headers: -> { { "Authorization" => "Bearer #{`gcloud auth application-default print-access-token`.strip}" } },
19
+ model:,
20
+ version: "vertex-2023-10-16",
21
+ http:
22
+ )
23
+ end
24
+
25
+ def initialize(endpoint:, headers:, model:, version: nil, http: Elelem::Net.http)
26
+ @endpoint = endpoint
27
+ @headers_source = headers
28
+ @model = model
29
+ @version = version
30
+ @http = http
31
+ end
32
+
33
+ def fetch(messages, tools = [], &block)
34
+ system_prompt, normalized_messages = extract_system(messages)
35
+ tool_calls = []
36
+
37
+ stream(normalized_messages, system_prompt, tools) do |event|
38
+ handle_event(event, tool_calls, &block)
39
+ end
40
+
41
+ finalize_tool_calls(tool_calls)
42
+ end
43
+
44
+ private
45
+
46
+ def headers
47
+ @headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
48
+ end
49
+
50
+ def handle_event(event, tool_calls, &block)
51
+ case event["type"]
52
+ when "content_block_start"
53
+ handle_content_block_start(event, tool_calls)
54
+ when "content_block_delta"
55
+ handle_content_block_delta(event, tool_calls, &block)
56
+ end
57
+ end
58
+
59
+ def handle_content_block_start(event, tool_calls)
60
+ content_block = event["content_block"]
61
+ return unless content_block["type"] == "tool_use"
62
+
63
+ tool_calls << {
64
+ id: content_block["id"],
65
+ name: content_block["name"],
66
+ args: String.new
67
+ }
68
+ end
69
+
70
+ def handle_content_block_delta(event, tool_calls, &block)
71
+ delta = event["delta"]
72
+
73
+ case delta["type"]
74
+ when "text_delta"
75
+ block.call(content: delta["text"], thinking: nil)
76
+ when "thinking_delta"
77
+ block.call(content: nil, thinking: delta["thinking"])
78
+ when "input_json_delta"
79
+ tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
80
+ end
81
+ end
82
+
83
+ def finalize_tool_calls(tool_calls)
84
+ tool_calls.each do |tool_call|
85
+ args = tool_call.delete(:args)
86
+ tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
87
+ end
88
+ end
89
+
90
+ def stream(messages, system_prompt, tools)
91
+ body = build_request_body(messages, system_prompt, tools)
92
+
93
+ @http.post(@endpoint, headers:, body:) do |response|
94
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
95
+
96
+ read_sse_stream(response) { |event| yield event }
97
+ end
98
+ end
99
+
100
+ def build_request_body(messages, system_prompt, tools)
101
+ body = { max_tokens: 64000, messages:, stream: true }
102
+ body[:model] = @model unless @version
103
+ body[:anthropic_version] = @version if @version
104
+ body[:system] = system_prompt if system_prompt
105
+ body[:tools] = unwrap_tools(tools) unless tools.empty?
106
+ body
107
+ end
108
+
109
+ def read_sse_stream(response)
110
+ buffer = String.new
111
+
112
+ response.read_body do |chunk|
113
+ buffer << chunk
114
+
115
+ while (index = buffer.index("\n\n"))
116
+ raw_event = buffer.slice!(0, index + 2)
117
+ event = parse_sse(raw_event)
118
+ yield event if event
119
+ end
120
+ end
121
+ end
122
+
123
+ def parse_sse(raw)
124
+ line = raw.lines.find { |l| l.start_with?("data: ") }
125
+ return nil unless line
126
+
127
+ data = line.delete_prefix("data: ").strip
128
+ return nil if data == "[DONE]"
129
+
130
+ JSON.parse(data)
131
+ end
132
+
133
+ def extract_system(messages)
134
+ system_messages, other_messages = messages.partition { |message| message[:role] == "system" }
135
+ system_content = system_messages.first&.dig(:content)
136
+ [system_content, normalize(other_messages)]
137
+ end
138
+
139
+ def normalize(messages)
140
+ messages.map { |message| normalize_message(message) }
141
+ end
142
+
143
+ def normalize_message(message)
144
+ case message[:role]
145
+ when "tool"
146
+ tool_result_message(message)
147
+ when "assistant"
148
+ message[:tool_calls]&.any? ? assistant_with_tools_message(message) : message
149
+ else
150
+ message
151
+ end
152
+ end
153
+
154
+ def tool_result_message(message)
155
+ {
156
+ role: "user",
157
+ content: [{
158
+ type: "tool_result",
159
+ tool_use_id: message[:tool_call_id],
160
+ content: message[:content]
161
+ }]
162
+ }
163
+ end
164
+
165
+ def assistant_with_tools_message(message)
166
+ text_content = build_text_content(message[:content])
167
+ tool_content = build_tool_content(message[:tool_calls])
168
+
169
+ { role: "assistant", content: text_content + tool_content }
170
+ end
171
+
172
+ def build_text_content(content)
173
+ return [] if content.to_s.empty?
174
+
175
+ [{ type: "text", text: content }]
176
+ end
177
+
178
+ def build_tool_content(tool_calls)
179
+ tool_calls.map do |tool_call|
180
+ {
181
+ type: "tool_use",
182
+ id: tool_call[:id],
183
+ name: tool_call[:name],
184
+ input: tool_call[:arguments]
185
+ }
186
+ end
187
+ end
188
+
189
+ def unwrap_tools(tools)
190
+ tools.map do |tool|
191
+ {
192
+ name: tool.dig(:function, :name),
193
+ description: tool.dig(:function, :description),
194
+ input_schema: tool.dig(:function, :parameters)
195
+ }
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Net
5
+ class Ollama
6
+ def initialize(model:, host: "localhost:11434", http: Elelem::Net.http)
7
+ @url = normalize_url(host)
8
+ @model = model
9
+ @http = http
10
+ end
11
+
12
+ def fetch(messages, tools = [], &block)
13
+ tool_calls = []
14
+ body = build_request_body(messages, tools)
15
+
16
+ stream(body) do |event|
17
+ handle_event(event, tool_calls, &block)
18
+ end
19
+
20
+ tool_calls
21
+ end
22
+
23
+ private
24
+
25
+ def normalize_url(host)
26
+ base = host.start_with?("http") ? host : "http://#{host}"
27
+ "#{base}/api/chat"
28
+ end
29
+
30
+ def build_request_body(messages, tools)
31
+ { model: @model, messages:, tools:, stream: true }
32
+ end
33
+
34
+ def handle_event(event, tool_calls, &block)
35
+ message = event["message"] || {}
36
+
37
+ unless event["done"]
38
+ block.call(content: message["content"], thinking: message["thinking"])
39
+ end
40
+
41
+ if message["tool_calls"]
42
+ tool_calls.concat(parse_tool_calls(message["tool_calls"]))
43
+ end
44
+ end
45
+
46
+ def stream(body)
47
+ @http.post(@url, body:) do |response|
48
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
49
+
50
+ read_ndjson_stream(response) { |event| yield event }
51
+ end
52
+ end
53
+
54
+ def read_ndjson_stream(response)
55
+ buffer = String.new
56
+
57
+ response.read_body do |chunk|
58
+ buffer << chunk
59
+
60
+ while (index = buffer.index("\n"))
61
+ line = buffer.slice!(0, index + 1)
62
+ yield JSON.parse(line)
63
+ end
64
+ end
65
+ end
66
+
67
+ def parse_tool_calls(tool_calls)
68
+ tool_calls.map do |tool_call|
69
+ {
70
+ id: tool_call["id"],
71
+ name: tool_call.dig("function", "name"),
72
+ arguments: tool_call.dig("function", "arguments") || {}
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Net
5
+ class OpenAI
6
+ def initialize(model:, api_key:, base_url: "https://api.openai.com/v1", http: Elelem::Net.http)
7
+ @url = "#{base_url}/chat/completions"
8
+ @model = model
9
+ @api_key = api_key
10
+ @http = http
11
+ end
12
+
13
+ def fetch(messages, tools = [], &block)
14
+ tool_calls = {}
15
+ body = build_request_body(messages, tools)
16
+
17
+ stream(body) do |event|
18
+ handle_event(event, tool_calls, &block)
19
+ end
20
+
21
+ finalize_tool_calls(tool_calls)
22
+ end
23
+
24
+ private
25
+
26
+ def build_request_body(messages, tools)
27
+ { model: @model, messages:, stream: true, tools:, tool_choice: "auto" }
28
+ end
29
+
30
+ def handle_event(event, tool_calls, &block)
31
+ delta = event.dig("choices", 0, "delta") || {}
32
+
33
+ block.call(content: delta["content"], thinking: nil) if delta["content"]
34
+
35
+ accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
36
+ end
37
+
38
+ def accumulate_tool_calls(incoming_tool_calls, tool_calls)
39
+ incoming_tool_calls.each do |tool_call|
40
+ index = tool_call["index"]
41
+ tool_calls[index] ||= { id: nil, name: nil, args: String.new }
42
+ tool_calls[index][:id] ||= tool_call["id"]
43
+ tool_calls[index][:name] ||= tool_call.dig("function", "name")
44
+ tool_calls[index][:args] << tool_call.dig("function", "arguments").to_s
45
+ end
46
+ end
47
+
48
+ def stream(body)
49
+ @http.post(@url, headers: headers, body:) do |response|
50
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
51
+
52
+ read_sse_stream(response) { |event| yield event }
53
+ end
54
+ end
55
+
56
+ def headers
57
+ { "Authorization" => "Bearer #{@api_key}" }
58
+ end
59
+
60
+ def read_sse_stream(response)
61
+ buffer = String.new
62
+
63
+ response.read_body do |chunk|
64
+ buffer << chunk
65
+
66
+ while (index = buffer.index("\n"))
67
+ line = buffer.slice!(0, index + 1).strip
68
+ next unless line.start_with?("data: ") && line != "data: [DONE]"
69
+
70
+ yield JSON.parse(line.delete_prefix("data: "))
71
+ end
72
+ end
73
+ end
74
+
75
+ def finalize_tool_calls(tool_calls)
76
+ tool_calls.values.map do |tool_call|
77
+ {
78
+ id: tool_call[:id],
79
+ name: tool_call[:name],
80
+ arguments: JSON.parse(tool_call[:args])
81
+ }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/elelem/net.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/hippie"
4
+ require "json"
5
+
6
+ require_relative "net/ollama"
7
+ require_relative "net/openai"
8
+ require_relative "net/claude"
9
+
10
+ module Elelem
11
+ module Net
12
+ def self.http
13
+ @http ||= ::Net::Hippie::Client.new(read_timeout: 3600, open_timeout: 10)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem::Plugins.register(:confirm) do |toolbox|
4
+ toolbox.before("execute") do |args|
5
+ next unless $stdin.tty?
6
+
7
+ cmd = args["command"]
8
+ $stdout.print " Allow? [Y/n] > "
9
+ answer = $stdin.gets&.strip&.downcase
10
+ raise "User denied permission to execute: #{cmd}" if answer == "n"
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem::Plugins.register(:edit) do |toolbox|
4
+ toolbox.add("edit",
5
+ description: "Replace first occurrence of text in file",
6
+ params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
7
+ required: ["path", "old", "new"]
8
+ ) do |a|
9
+ path = Pathname.new(a["path"]).expand_path
10
+ content = path.read
11
+ toolbox
12
+ .run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
13
+ .merge(replaced: a["old"], with: a["new"])
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem::Plugins.register(:eval) do |toolbox|
4
+ description = <<~'DESC'
5
+ Evaluate Ruby code. Available API:
6
+
7
+ name = "search"
8
+ toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
9
+ toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
10
+ end
11
+ DESC
12
+
13
+ toolbox.add("eval",
14
+ description: description,
15
+ params: { ruby: { type: "string" } },
16
+ required: ["ruby"]
17
+ ) do |args|
18
+ { result: binding.eval(args["ruby"]) }
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem::Plugins.register(:execute) do |toolbox|
4
+ toolbox.add("execute",
5
+ description: "Run shell command (supports pipes and redirections)",
6
+ params: { command: { type: "string" } },
7
+ required: ["command"],
8
+ aliases: ["bash", "sh", "exec", "execute<|channel|>"]
9
+ ) do |a|
10
+ Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) }
11
+ end
12
+
13
+ toolbox.after("execute") do |args, result|
14
+ return if result[:exit_status] == 0
15
+
16
+ $stdout.puts toolbox.header("execute", args, state: "x")
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem::Plugins.register(:mcp) do |toolbox|
4
+ mcp = Elelem::MCP.new
5
+ at_exit { mcp.close }
6
+ mcp.tools.each do |name, tool|
7
+ toolbox.add(name,
8
+ description: tool[:description],
9
+ params: tool[:params],
10
+ required: tool[:required],
11
+ &tool[:fn]
12
+ )
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem::Plugins.register(:read) do |toolbox|
4
+ toolbox.add("read",
5
+ description: "Read file",
6
+ params: { path: { type: "string" } },
7
+ required: ["path"],
8
+ aliases: ["open"]
9
+ ) do |a|
10
+ path = Pathname.new(a["path"]).expand_path
11
+ path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
12
+ end
13
+
14
+ toolbox.after("read") do |_, result|
15
+ if result[:error]
16
+ $stdout.puts " ! #{result[:error]}"
17
+ elsif !system("bat", "--paging=never", result[:path])
18
+ $stdout.puts result[:content]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Verifiers
5
+ SYNTAX = {
6
+ ".rb" => "ruby -c %{path}",
7
+ ".erb" => "erb -x %{path} | ruby -c",
8
+ ".py" => "python -m py_compile %{path}",
9
+ ".go" => "go vet %{path}",
10
+ ".rs" => "cargo check --quiet",
11
+ ".ts" => "npx tsc --noEmit %{path}",
12
+ ".js" => "node --check %{path}",
13
+ }.freeze
14
+
15
+ def self.for(path)
16
+ return [] unless path
17
+
18
+ cmds = []
19
+ ext = File.extname(path)
20
+ cmds << (SYNTAX[ext] % { path: path }) if SYNTAX[ext]
21
+ cmds << test_runner
22
+ cmds.compact
23
+ end
24
+
25
+ def self.test_runner
26
+ %w[bin/test script/test].find { |s| File.executable?(s) }
27
+ end
28
+ end
29
+
30
+ Plugins.register(:verify) do |toolbox|
31
+ toolbox.add("verify",
32
+ description: "Verify file syntax and run tests",
33
+ params: { path: { type: "string" } },
34
+ required: ["path"]
35
+ ) do |a|
36
+ path = a["path"]
37
+ Verifiers.for(path).inject({verified: []}) do |memo, cmd|
38
+ $stdout.puts toolbox.header("execute", { "command" => cmd })
39
+ v = toolbox.run("execute", { "command" => cmd })
40
+ return v.merge(path: path, command: cmd) if v[:exit_status] != 0
41
+
42
+ memo[:verified] << cmd
43
+ memo
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem::Plugins.register(:write) do |toolbox|
4
+ toolbox.add("write",
5
+ description: "Write file",
6
+ params: { path: { type: "string" }, content: { type: "string" } },
7
+ required: ["path", "content"],
8
+ aliases: ["write<|channel|>"]
9
+ ) do |a|
10
+ path = Pathname.new(a["path"]).expand_path
11
+ FileUtils.mkdir_p(path.dirname)
12
+ { bytes: path.write(a["content"]), path: a["path"] }
13
+ end
14
+
15
+ toolbox.after("write") do |_, result|
16
+ if result[:error]
17
+ $stdout.puts " ! #{result[:error]}"
18
+ else
19
+ system("bat", "--paging=never", result[:path]) || $stdout.puts(" -> #{result[:path]}")
20
+ toolbox.run("verify", { "path" => result[:path] })
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Plugins
5
+ LOAD_PATHS = [
6
+ File.expand_path("plugins", __dir__),
7
+ "~/.elelem/plugins",
8
+ ".elelem/plugins"
9
+ ].freeze
10
+
11
+ def self.setup!(toolbox)
12
+ load_plugins
13
+ registry.each_value { |plugin| plugin.call(toolbox) }
14
+ end
15
+
16
+ def self.reload!(toolbox)
17
+ @registry = {}
18
+ load_plugins
19
+ registry.each_value { |plugin| plugin.call(toolbox) }
20
+ end
21
+
22
+ def self.load_plugins
23
+ LOAD_PATHS.each do |path|
24
+ dir = File.expand_path(path)
25
+ next unless File.directory?(dir)
26
+
27
+ Dir["#{dir}/*.rb"].sort.each do |file|
28
+ load(file)
29
+ rescue => e
30
+ warn "elelem: failed to load plugin #{file}: #{e.message}"
31
+ end
32
+ end
33
+ end
34
+
35
+ def self.register(name, &block)
36
+ (@registry ||= {})[name] = block
37
+ end
38
+
39
+ def self.registry
40
+ @registry ||= {}
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class SystemPrompt
5
+ TEMPLATE_PATH = File.expand_path("templates/system_prompt.erb", __dir__)
6
+
7
+ attr_reader :memory
8
+
9
+ def initialize(memory: nil)
10
+ @memory = memory
11
+ end
12
+
13
+ def render
14
+ ERB.new(template, trim_mode: "-").result(binding)
15
+ end
16
+
17
+ private
18
+
19
+ def template
20
+ File.read(TEMPLATE_PATH)
21
+ end
22
+
23
+ def pwd
24
+ Dir.pwd
25
+ end
26
+
27
+ def elelem_source
28
+ File.expand_path("../..", __dir__)
29
+ end
30
+
31
+ def platform
32
+ RUBY_PLATFORM.split("-").last
33
+ end
34
+
35
+ def date
36
+ Date.today
37
+ end
38
+
39
+ def git_branch
40
+ return unless File.exist?(".git")
41
+
42
+ "branch: #{`git branch --show-current`.strip}"
43
+ rescue
44
+ nil
45
+ end
46
+
47
+ def repo_map
48
+ `ctags -x --sort=no --languages=Ruby,Python,JavaScript,TypeScript,Go,Rust -R . 2>/dev/null`
49
+ .lines
50
+ .reject { |l| l.include?("vendor/") || l.include?("node_modules/") || l.include?("spec/") }
51
+ .first(100)
52
+ .join
53
+ rescue
54
+ ""
55
+ end
56
+
57
+ def agents_md
58
+ Pathname.pwd.ascend.each do |dir|
59
+ file = dir / "AGENTS.md"
60
+ return file.read if file.exist?
61
+ end
62
+ nil
63
+ end
64
+ end
65
+ end