elelem 0.10.0 → 0.12.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -195
  3. data/exe/elelem +7 -1
  4. data/lib/elelem/agent.rb +68 -62
  5. data/lib/elelem/cli.rb +116 -0
  6. data/lib/elelem/commands.rb +26 -10
  7. data/lib/elelem/config.rb +17 -0
  8. data/lib/elelem/conversation.rb +1 -0
  9. data/lib/elelem/input.rb +51 -0
  10. data/lib/elelem/null_input.rb +8 -0
  11. data/lib/elelem/null_output.rb +7 -0
  12. data/lib/elelem/null_tool.rb +18 -0
  13. data/lib/elelem/output.rb +55 -0
  14. data/lib/elelem/plugins.rb +20 -27
  15. data/lib/elelem/prompts/default.erb +3 -0
  16. data/lib/elelem/registration.rb +37 -0
  17. data/lib/elelem/registry.rb +68 -0
  18. data/lib/elelem/repl.rb +34 -0
  19. data/lib/elelem/result.rb +27 -0
  20. data/lib/elelem/schema.rb +20 -0
  21. data/lib/elelem/session.rb +17 -0
  22. data/lib/elelem/slash_command.rb +18 -0
  23. data/lib/elelem/stub_provider.rb +15 -0
  24. data/lib/elelem/system_prompt.rb +12 -135
  25. data/lib/elelem/tool.rb +7 -12
  26. data/lib/elelem/toolbox.rb +40 -31
  27. data/lib/elelem/transcript.rb +26 -0
  28. data/lib/elelem/version.rb +2 -2
  29. data/lib/elelem.rb +28 -113
  30. metadata +48 -155
  31. data/CHANGELOG.md +0 -309
  32. data/lib/elelem/mcp/oauth.rb +0 -217
  33. data/lib/elelem/mcp/token_storage.rb +0 -60
  34. data/lib/elelem/mcp.rb +0 -243
  35. data/lib/elelem/net/claude.rb +0 -202
  36. data/lib/elelem/net/ollama.rb +0 -81
  37. data/lib/elelem/net/openai.rb +0 -88
  38. data/lib/elelem/net.rb +0 -13
  39. data/lib/elelem/permissions.rb +0 -45
  40. data/lib/elelem/plugins/builtins.rb +0 -96
  41. data/lib/elelem/plugins/edit.rb +0 -15
  42. data/lib/elelem/plugins/eval.rb +0 -20
  43. data/lib/elelem/plugins/execute.rb +0 -18
  44. data/lib/elelem/plugins/git.rb +0 -20
  45. data/lib/elelem/plugins/glob.rb +0 -13
  46. data/lib/elelem/plugins/grep.rb +0 -21
  47. data/lib/elelem/plugins/list.rb +0 -14
  48. data/lib/elelem/plugins/mcp.rb +0 -20
  49. data/lib/elelem/plugins/permissions.json +0 -6
  50. data/lib/elelem/plugins/read.rb +0 -21
  51. data/lib/elelem/plugins/task.rb +0 -14
  52. data/lib/elelem/plugins/tools.rb +0 -13
  53. data/lib/elelem/plugins/verify.rb +0 -47
  54. data/lib/elelem/plugins/write.rb +0 -34
  55. data/lib/elelem/plugins/zz_confirm.rb +0 -9
  56. data/lib/elelem/terminal.rb +0 -103
data/lib/elelem/mcp.rb DELETED
@@ -1,243 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "mcp/token_storage"
4
- require_relative "mcp/oauth"
5
-
6
- module Elelem
7
- # https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
8
- class MCP
9
- CONFIG_PATHS = [
10
- "~/.elelem/mcp.json",
11
- ".elelem/mcp.json"
12
- ].freeze
13
-
14
- def initialize(configurations = CONFIG_PATHS)
15
- @config = load_config(configurations)
16
- @servers = {}
17
- end
18
-
19
- def tools
20
- @config.fetch("mcpServers", {}).flat_map do |name, _|
21
- server(name).tools.map do |tool|
22
- [
23
- "#{name}_#{tool["name"]}",
24
- {
25
- description: tool["description"],
26
- params: tool.dig("inputSchema", "properties") || {},
27
- required: tool.dig("inputSchema", "required") || [],
28
- fn: ->(a) { server(name).call(tool["name"], a) }
29
- }
30
- ]
31
- end
32
- end.to_h
33
- end
34
-
35
- def close
36
- @servers.each_value(&:close)
37
- end
38
-
39
- private
40
-
41
- def load_config(configurations)
42
- configurations.each_with_object({}) do |path, merged|
43
- file = File.expand_path(path)
44
- next unless File.exist?(file)
45
-
46
- config = JSON.parse(IO.read(file))
47
- servers = config.fetch("mcpServers", {})
48
- merged["mcpServers"] = (merged["mcpServers"] || {}).merge(servers)
49
- end
50
- end
51
-
52
- def server(name)
53
- @servers[name] ||= build_server(@config.dig("mcpServers", name))
54
- end
55
-
56
- def build_server(config)
57
- if config["type"] == "http"
58
- HttpServer.new(url: config["url"], headers: config["headers"] || {})
59
- else
60
- Server.new(**config.transform_keys(&:to_sym))
61
- end
62
- end
63
-
64
- module ServerInterface
65
- def tools
66
- request("tools/list")["tools"]
67
- end
68
-
69
- def call(name, args)
70
- result = request("tools/call", { name: name, arguments: args })
71
- logger.info({ tool: name, args: args, result: result }.to_json)
72
- content = extract_content(result)
73
- result["isError"] ? { error: content } : { content: content }
74
- end
75
-
76
- def extract_content(result)
77
- if (structured = result["structuredContent"])
78
- structured
79
- else
80
- result["content"]&.map { |c| c["text"] }&.join("\n")
81
- end
82
- end
83
-
84
- def logger
85
- @logger ||= Logger.new(File.expand_path("~/.elelem/mcp.log"))
86
- end
87
-
88
- private
89
-
90
- def handshake!
91
- request("initialize", {
92
- protocolVersion: "2025-06-18",
93
- capabilities: {},
94
- clientInfo: { name: "elelem", version: VERSION }
95
- })
96
- notify("notifications/initialized")
97
- end
98
- end
99
-
100
- class Server
101
- include ServerInterface
102
-
103
- def initialize(command:, args: [], env: {})
104
- resolved_env = env.transform_values do |v|
105
- v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
106
- end
107
- @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
108
- @id = 0
109
- handshake!
110
- end
111
-
112
- def close
113
- [@stdin, @stdout, @stderr].each { |io| io.close rescue nil }
114
- @wait.kill rescue nil
115
- end
116
-
117
- private
118
-
119
- def request(method, params = {})
120
- send_msg(id: @id += 1, method: method, params: params)
121
- read_response(@id)
122
- end
123
-
124
- def notify(method, params = {})
125
- send_msg(method: method, params: params)
126
- end
127
-
128
- def send_msg(msg)
129
- @stdin.puts({ jsonrpc: "2.0", **msg }.to_json)
130
- @stdin.flush
131
- end
132
-
133
- def read_response(id)
134
- loop do
135
- line = @stdout.gets
136
- raise "Server closed" unless line
137
- msg = JSON.parse(line)
138
- return msg["result"] if msg["id"] == id
139
- raise msg["error"]["message"] if msg["error"]
140
- end
141
- end
142
- end
143
-
144
- class HttpServer
145
- include ServerInterface
146
-
147
- def initialize(url:, headers: {}, http: Elelem::Net.http)
148
- @url = url
149
- @headers = resolve_headers(headers)
150
- @http = http
151
- @id = 0
152
- @session_id = nil
153
- @access_token = nil
154
- handshake!
155
- end
156
-
157
- def close
158
- end
159
-
160
- private
161
-
162
- def resolve_headers(headers)
163
- headers.transform_values do |v|
164
- v.gsub(/\$\{(\w+)\}/) do
165
- ENV[$1] || raise("Missing environment variable: #{$1}")
166
- end
167
- end
168
- end
169
-
170
- def request(method, params = {})
171
- msg = { jsonrpc: "2.0", id: @id += 1, method: method, params: params }
172
- response = post(msg)
173
- raise response["error"]["message"] if response["error"]
174
- response["result"]
175
- end
176
-
177
- def notify(method, params = {})
178
- msg = { jsonrpc: "2.0", method: method, params: params }
179
- post(msg)
180
- end
181
-
182
- def post(msg, retry_auth: true)
183
- result = nil
184
- needs_auth = false
185
- error = nil
186
-
187
- @http.post(@url, headers: request_headers, body: msg) do |response|
188
- case response
189
- when ::Net::HTTPSuccess
190
- @session_id ||= response["Mcp-Session-Id"]
191
- result = parse_response(response)
192
- when ::Net::HTTPUnauthorized
193
- needs_auth = true
194
- else
195
- error = "HTTP #{response.code}: #{response.body}"
196
- end
197
- end
198
-
199
- raise error if error
200
- if needs_auth
201
- raise "Authorization failed" unless retry_auth
202
-
203
- @access_token = OAuth.new(@url, http: @http).token
204
- return post(msg, retry_auth: false)
205
- end
206
- result
207
- end
208
-
209
- def request_headers
210
- base = { "Accept" => "application/json, text/event-stream" }
211
- base["Mcp-Session-Id"] = @session_id if @session_id
212
- base["Authorization"] = "Bearer #{@access_token}" if @access_token
213
- @headers.merge(base)
214
- end
215
-
216
- def parse_response(response)
217
- if response.content_type&.include?("text/event-stream")
218
- parse_sse(response)
219
- elsif response.body && !response.body.empty?
220
- JSON.parse(response.body)
221
- end
222
- end
223
-
224
- def parse_sse(response)
225
- buffer = String.new
226
- result = nil
227
-
228
- response.read_body do |chunk|
229
- buffer << chunk
230
-
231
- while (index = buffer.index("\n"))
232
- line = buffer.slice!(0, index + 1).strip
233
- next unless line.start_with?("data: ")
234
-
235
- result = JSON.parse(line.delete_prefix("data: "))
236
- end
237
- end
238
-
239
- result
240
- end
241
- end
242
- end
243
- end
@@ -1,202 +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, &block)
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(type: "saying", text: delta["text"])
76
- when "thinking_delta"
77
- block.call(type: "thinking", text: 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, &block)
84
- tool_calls.each do |tool_call|
85
- args = tool_call.delete(:args)
86
- tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
87
- block.call(type: "tool_call", id: tool_call[:id], name: tool_call[:name], arguments: tool_call[:arguments])
88
- end
89
- tool_calls
90
- end
91
-
92
- def stream(messages, system_prompt, tools)
93
- body = build_request_body(messages, system_prompt, tools)
94
-
95
- @http.post(@endpoint, headers:, body:) do |response|
96
- raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
97
-
98
- read_sse_stream(response) { |event| yield event }
99
- end
100
- end
101
-
102
- def build_request_body(messages, system_prompt, tools)
103
- body = { max_tokens: 64000, messages:, stream: true }
104
- body[:model] = @model unless @version
105
- body[:anthropic_version] = @version if @version
106
- body[:system] = system_prompt if system_prompt
107
- body[:tools] = unwrap_tools(tools) unless tools.empty?
108
- body
109
- end
110
-
111
- def read_sse_stream(response)
112
- buffer = String.new
113
-
114
- response.read_body do |chunk|
115
- buffer << chunk
116
-
117
- while (index = buffer.index("\n\n"))
118
- raw_event = buffer.slice!(0, index + 2)
119
- event = parse_sse(raw_event)
120
- yield event if event
121
- end
122
- end
123
- end
124
-
125
- def parse_sse(raw)
126
- line = raw.lines.find { |l| l.start_with?("data: ") }
127
- return nil unless line
128
-
129
- data = line.delete_prefix("data: ").strip
130
- return nil if data == "[DONE]"
131
-
132
- JSON.parse(data)
133
- end
134
-
135
- def extract_system(messages)
136
- system_messages, other_messages = messages.partition { |message| message[:role] == "system" }
137
- system_content = system_messages.first&.dig(:content)
138
- [system_content, normalize(other_messages)]
139
- end
140
-
141
- def normalize(messages)
142
- messages.map { |message| normalize_message(message) }
143
- end
144
-
145
- def normalize_message(message)
146
- case message[:role]
147
- when "tool"
148
- tool_result_message(message)
149
- when "assistant"
150
- message[:tool_calls]&.any? ? assistant_with_tools_message(message) : message
151
- else
152
- message
153
- end
154
- end
155
-
156
- def tool_result_message(message)
157
- {
158
- role: "user",
159
- content: [{
160
- type: "tool_result",
161
- tool_use_id: message[:tool_call_id],
162
- content: message[:content]
163
- }]
164
- }
165
- end
166
-
167
- def assistant_with_tools_message(message)
168
- text_content = build_text_content(message[:content])
169
- tool_content = build_tool_content(message[:tool_calls])
170
-
171
- { role: "assistant", content: text_content + tool_content }
172
- end
173
-
174
- def build_text_content(content)
175
- return [] if content.to_s.empty?
176
-
177
- [{ type: "text", text: content }]
178
- end
179
-
180
- def build_tool_content(tool_calls)
181
- tool_calls.map do |tool_call|
182
- {
183
- type: "tool_use",
184
- id: tool_call[:id],
185
- name: tool_call[:name],
186
- input: tool_call[:arguments]
187
- }
188
- end
189
- end
190
-
191
- def unwrap_tools(tools)
192
- tools.map do |tool|
193
- {
194
- name: tool.dig(:function, :name),
195
- description: tool.dig(:function, :description),
196
- input_schema: tool.dig(:function, :parameters)
197
- }
198
- end
199
- end
200
- end
201
- end
202
- end
@@ -1,81 +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(type: "saying", text: message["content"]) if message["content"]
39
- block.call(type: "thinking", text: message["thinking"]) if message["thinking"]
40
- end
41
-
42
- if message["tool_calls"]
43
- parsed = parse_tool_calls(message["tool_calls"])
44
- parsed.each { |tc| block.call(type: "tool_call", **tc) }
45
- tool_calls.concat(parsed)
46
- end
47
- end
48
-
49
- def stream(body)
50
- @http.post(@url, body:) do |response|
51
- raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
52
-
53
- read_ndjson_stream(response) { |event| yield event }
54
- end
55
- end
56
-
57
- def read_ndjson_stream(response)
58
- buffer = String.new
59
-
60
- response.read_body do |chunk|
61
- buffer << chunk
62
-
63
- while (index = buffer.index("\n"))
64
- line = buffer.slice!(0, index + 1)
65
- yield JSON.parse(line)
66
- end
67
- end
68
- end
69
-
70
- def parse_tool_calls(tool_calls)
71
- tool_calls.map do |tool_call|
72
- {
73
- id: tool_call["id"],
74
- name: tool_call.dig("function", "name"),
75
- arguments: tool_call.dig("function", "arguments") || {}
76
- }
77
- end
78
- end
79
- end
80
- end
81
- end
@@ -1,88 +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, &block)
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(type: "saying", text: delta["content"]) 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, &block)
76
- tool_calls.values.map do |tool_call|
77
- result = {
78
- id: tool_call[:id],
79
- name: tool_call[:name],
80
- arguments: JSON.parse(tool_call[:args])
81
- }
82
- block.call(type: "tool_call", **result)
83
- result
84
- end
85
- end
86
- end
87
- end
88
- end
data/lib/elelem/net.rb DELETED
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "net/ollama"
4
- require_relative "net/openai"
5
- require_relative "net/claude"
6
-
7
- module Elelem
8
- module Net
9
- def self.http
10
- @http ||= ::Net::Hippie::Client.new(read_timeout: 3600, open_timeout: 10)
11
- end
12
- end
13
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- class Permissions
5
- LOAD_PATHS = [
6
- File.expand_path("plugins/permissions.json", __dir__),
7
- "~/.elelem/permissions.json",
8
- ".elelem/permissions.json"
9
- ].freeze
10
-
11
- def initialize
12
- @rules = LOAD_PATHS.reduce({}) do |rules, path|
13
- rules.merge(load_config(File.expand_path(path)))
14
- end
15
- end
16
-
17
- def check(tool_name, args, terminal:)
18
- policy = @rules[tool_name.to_sym] || :ask
19
- case policy
20
- when :allow then true
21
- when :deny then raise "Permission denied: #{tool_name}"
22
- when :ask then prompt(tool_name, args, terminal)
23
- end
24
- end
25
-
26
- private
27
-
28
- def load_config(path)
29
- return {} unless File.exist?(path)
30
-
31
- JSON.parse(File.read(path)).transform_keys(&:to_sym).transform_values(&:to_sym)
32
- rescue JSON::ParserError
33
- {}
34
- end
35
-
36
- def prompt(tool_name, args, terminal)
37
- return true unless $stdin.tty?
38
-
39
- answer = terminal.ask(" Allow? [Y/n] > ")&.downcase
40
- raise "User denied permission: #{tool_name}" if answer == "n"
41
-
42
- true
43
- end
44
- end
45
- end