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.
- checksums.yaml +4 -4
- data/README.md +8 -195
- data/exe/elelem +7 -1
- data/lib/elelem/agent.rb +68 -62
- data/lib/elelem/cli.rb +116 -0
- data/lib/elelem/commands.rb +26 -10
- data/lib/elelem/config.rb +17 -0
- data/lib/elelem/conversation.rb +1 -0
- data/lib/elelem/input.rb +51 -0
- data/lib/elelem/null_input.rb +8 -0
- data/lib/elelem/null_output.rb +7 -0
- data/lib/elelem/null_tool.rb +18 -0
- data/lib/elelem/output.rb +55 -0
- data/lib/elelem/plugins.rb +20 -27
- data/lib/elelem/prompts/default.erb +3 -0
- data/lib/elelem/registration.rb +37 -0
- data/lib/elelem/registry.rb +68 -0
- data/lib/elelem/repl.rb +34 -0
- data/lib/elelem/result.rb +27 -0
- data/lib/elelem/schema.rb +20 -0
- data/lib/elelem/session.rb +17 -0
- data/lib/elelem/slash_command.rb +18 -0
- data/lib/elelem/stub_provider.rb +15 -0
- data/lib/elelem/system_prompt.rb +12 -135
- data/lib/elelem/tool.rb +7 -12
- data/lib/elelem/toolbox.rb +40 -31
- data/lib/elelem/transcript.rb +26 -0
- data/lib/elelem/version.rb +2 -2
- data/lib/elelem.rb +28 -113
- metadata +48 -155
- data/CHANGELOG.md +0 -309
- data/lib/elelem/mcp/oauth.rb +0 -217
- data/lib/elelem/mcp/token_storage.rb +0 -60
- data/lib/elelem/mcp.rb +0 -243
- data/lib/elelem/net/claude.rb +0 -202
- data/lib/elelem/net/ollama.rb +0 -81
- data/lib/elelem/net/openai.rb +0 -88
- data/lib/elelem/net.rb +0 -13
- data/lib/elelem/permissions.rb +0 -45
- data/lib/elelem/plugins/builtins.rb +0 -96
- data/lib/elelem/plugins/edit.rb +0 -15
- data/lib/elelem/plugins/eval.rb +0 -20
- data/lib/elelem/plugins/execute.rb +0 -18
- data/lib/elelem/plugins/git.rb +0 -20
- data/lib/elelem/plugins/glob.rb +0 -13
- data/lib/elelem/plugins/grep.rb +0 -21
- data/lib/elelem/plugins/list.rb +0 -14
- data/lib/elelem/plugins/mcp.rb +0 -20
- data/lib/elelem/plugins/permissions.json +0 -6
- data/lib/elelem/plugins/read.rb +0 -21
- data/lib/elelem/plugins/task.rb +0 -14
- data/lib/elelem/plugins/tools.rb +0 -13
- data/lib/elelem/plugins/verify.rb +0 -47
- data/lib/elelem/plugins/write.rb +0 -34
- data/lib/elelem/plugins/zz_confirm.rb +0 -9
- 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
|
data/lib/elelem/net/claude.rb
DELETED
|
@@ -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
|
data/lib/elelem/net/ollama.rb
DELETED
|
@@ -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
|
data/lib/elelem/net/openai.rb
DELETED
|
@@ -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
|
data/lib/elelem/permissions.rb
DELETED
|
@@ -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
|