elelem 0.9.2 → 0.11.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -118
  3. data/Rakefile +0 -11
  4. data/exe/elelem +8 -79
  5. data/lib/elelem/agent.rb +51 -142
  6. data/lib/elelem/cli.rb +76 -0
  7. data/lib/elelem/commands.rb +49 -0
  8. data/lib/elelem/config.rb +17 -0
  9. data/lib/elelem/conversation.rb +25 -0
  10. data/lib/elelem/input.rb +53 -0
  11. data/lib/elelem/null_input.rb +8 -0
  12. data/lib/elelem/null_output.rb +7 -0
  13. data/lib/elelem/null_tool.rb +18 -0
  14. data/lib/elelem/output.rb +57 -0
  15. data/lib/elelem/plugins.rb +10 -21
  16. data/lib/elelem/prompts/default.erb +3 -0
  17. data/lib/elelem/registration.rb +37 -0
  18. data/lib/elelem/registry.rb +69 -0
  19. data/lib/elelem/result.rb +26 -0
  20. data/lib/elelem/slash_command.rb +18 -0
  21. data/lib/elelem/stub_provider.rb +15 -0
  22. data/lib/elelem/system_prompt.rb +4 -42
  23. data/lib/elelem/tool.rb +6 -14
  24. data/lib/elelem/toolbox.rb +23 -23
  25. data/lib/elelem/version.rb +2 -2
  26. data/lib/elelem.rb +29 -27
  27. metadata +49 -65
  28. data/CHANGELOG.md +0 -280
  29. data/lib/elelem/mcp.rb +0 -96
  30. data/lib/elelem/net/claude.rb +0 -200
  31. data/lib/elelem/net/ollama.rb +0 -78
  32. data/lib/elelem/net/openai.rb +0 -86
  33. data/lib/elelem/net.rb +0 -16
  34. data/lib/elelem/plugins/confirm.rb +0 -12
  35. data/lib/elelem/plugins/edit.rb +0 -15
  36. data/lib/elelem/plugins/eval.rb +0 -20
  37. data/lib/elelem/plugins/execute.rb +0 -18
  38. data/lib/elelem/plugins/mcp.rb +0 -14
  39. data/lib/elelem/plugins/read.rb +0 -21
  40. data/lib/elelem/plugins/verify.rb +0 -47
  41. data/lib/elelem/plugins/write.rb +0 -23
  42. data/lib/elelem/templates/system_prompt.erb +0 -53
  43. data/lib/elelem/terminal.rb +0 -97
@@ -1,200 +0,0 @@
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
@@ -1,78 +0,0 @@
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
@@ -1,86 +0,0 @@
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 DELETED
@@ -1,16 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1,18 +0,0 @@
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
- next if result[:exit_status] == 0
15
-
16
- $stdout.puts toolbox.header("execute", args, state: "x")
17
- end
18
- end
@@ -1,14 +0,0 @@
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
@@ -1,21 +0,0 @@
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
@@ -1,47 +0,0 @@
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
- break 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
@@ -1,23 +0,0 @@
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
@@ -1,53 +0,0 @@
1
- Terminal coding agent. Be concise. Verify your work.
2
-
3
- # Tools
4
- - read(path): file contents
5
- - write(path, content): create/overwrite file
6
- - execute(command): shell command
7
- - eval(ruby): execute Ruby code; use to create tools for repetitive tasks
8
- - task(prompt): delegate complex searches or multi-file analysis to a focused subagent
9
-
10
- # Editing
11
- Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
12
- Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
13
- Use write for new files or full rewrites
14
-
15
- # Search
16
- Use execute(`rg`) for text search: `rg -n "pattern" .`
17
- Use execute(`fd`) for file discovery: `fd -e rb .`
18
- Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
19
-
20
- # Task Management
21
- For complex tasks:
22
- 1. State plan before acting
23
- 2. Work through steps one at a time
24
- 3. Summarize what was done
25
-
26
- # Long Tasks
27
- For complex multi-step work, write notes to .elelem/scratch.md
28
-
29
- # Policy
30
- - Explain before non-trivial commands
31
- - Verify changes (read file, run tests)
32
- - No interactive flags (-i, -p)
33
- - Use `man` when you need to understand how to execute a program
34
-
35
- # Environment
36
- pwd: <%= pwd %>
37
- platform: <%= platform %>
38
- date: <%= date %>
39
- self (this agent's source): <%= elelem_source %>
40
- <%= git_branch %>
41
-
42
- # Codebase
43
- <%= repo_map %>
44
- <% if agents_md %>
45
-
46
- # Project Instructions
47
- <%= agents_md %>
48
- <% end %>
49
- <% if memory %>
50
-
51
- # Earlier Context
52
- <%= memory %>
53
- <% end %>