elelem 0.9.1 → 0.10.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/CHANGELOG.md +34 -0
- data/README.md +93 -16
- data/Rakefile +0 -11
- data/exe/elelem +2 -79
- data/lib/elelem/agent.rb +33 -124
- data/lib/elelem/commands.rb +33 -0
- data/lib/elelem/conversation.rb +25 -0
- data/lib/elelem/mcp/oauth.rb +217 -0
- data/lib/elelem/mcp/token_storage.rb +60 -0
- data/lib/elelem/mcp.rb +164 -17
- data/lib/elelem/net/claude.rb +6 -4
- data/lib/elelem/net/ollama.rb +5 -2
- data/lib/elelem/net/openai.rb +6 -4
- data/lib/elelem/net.rb +0 -3
- data/lib/elelem/permissions.rb +45 -0
- data/lib/elelem/plugins/builtins.rb +96 -0
- data/lib/elelem/plugins/edit.rb +3 -3
- data/lib/elelem/plugins/eval.rb +4 -4
- data/lib/elelem/plugins/execute.rb +5 -5
- data/lib/elelem/plugins/git.rb +20 -0
- data/lib/elelem/plugins/glob.rb +13 -0
- data/lib/elelem/plugins/grep.rb +21 -0
- data/lib/elelem/plugins/list.rb +14 -0
- data/lib/elelem/plugins/mcp.rb +14 -8
- data/lib/elelem/plugins/permissions.json +6 -0
- data/lib/elelem/plugins/read.rb +6 -6
- data/lib/elelem/plugins/task.rb +14 -0
- data/lib/elelem/plugins/tools.rb +13 -0
- data/lib/elelem/plugins/verify.rb +4 -4
- data/lib/elelem/plugins/write.rb +17 -6
- data/lib/elelem/plugins/zz_confirm.rb +9 -0
- data/lib/elelem/plugins.rb +6 -6
- data/lib/elelem/system_prompt.rb +123 -29
- data/lib/elelem/terminal.rb +7 -1
- data/lib/elelem/tool.rb +6 -15
- data/lib/elelem/toolbox.rb +13 -4
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +96 -5
- metadata +99 -3
- data/lib/elelem/plugins/confirm.rb +0 -12
- data/lib/elelem/templates/system_prompt.erb +0 -53
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class MCP
|
|
5
|
+
class OAuth
|
|
6
|
+
CALLBACK_PORT = 18273
|
|
7
|
+
REDIRECT_URI = "http://127.0.0.1:#{CALLBACK_PORT}/callback"
|
|
8
|
+
|
|
9
|
+
def initialize(resource_url, http: Elelem::Net.http)
|
|
10
|
+
@resource_url = resource_url
|
|
11
|
+
@http = http
|
|
12
|
+
@storage = TokenStorage.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def token
|
|
16
|
+
stored = @storage.load(@resource_url)
|
|
17
|
+
return stored[:access_token] if stored && !expired?(stored)
|
|
18
|
+
return refresh(stored[:refresh_token]) if stored&.dig(:refresh_token)
|
|
19
|
+
|
|
20
|
+
authorize
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def expired?(stored)
|
|
26
|
+
return false unless stored[:expires_at]
|
|
27
|
+
|
|
28
|
+
Time.now.to_i >= stored[:expires_at] - 60
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def authorize
|
|
32
|
+
metadata = discover_auth_server
|
|
33
|
+
client = load_or_register_client(metadata)
|
|
34
|
+
verifier, challenge = generate_pkce
|
|
35
|
+
state = SecureRandom.hex(16)
|
|
36
|
+
|
|
37
|
+
auth_url = build_auth_url(metadata, client, challenge, state)
|
|
38
|
+
open_browser(auth_url)
|
|
39
|
+
|
|
40
|
+
code = wait_for_callback(state)
|
|
41
|
+
tokens = exchange_code(metadata, client, code, verifier)
|
|
42
|
+
|
|
43
|
+
@storage.save(
|
|
44
|
+
@resource_url,
|
|
45
|
+
access_token: tokens["access_token"],
|
|
46
|
+
refresh_token: tokens["refresh_token"],
|
|
47
|
+
expires_in: tokens["expires_in"]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
tokens["access_token"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def refresh(refresh_token)
|
|
54
|
+
metadata = discover_auth_server
|
|
55
|
+
client = load_or_register_client(metadata)
|
|
56
|
+
uri = URI.parse(metadata["token_endpoint"])
|
|
57
|
+
|
|
58
|
+
body = {
|
|
59
|
+
grant_type: "refresh_token",
|
|
60
|
+
refresh_token: refresh_token,
|
|
61
|
+
client_id: client[:client_id]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
response = post_form(uri, body)
|
|
65
|
+
tokens = JSON.parse(response.body)
|
|
66
|
+
|
|
67
|
+
@storage.save(
|
|
68
|
+
@resource_url,
|
|
69
|
+
access_token: tokens["access_token"],
|
|
70
|
+
refresh_token: tokens["refresh_token"] || refresh_token,
|
|
71
|
+
expires_in: tokens["expires_in"]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
tokens["access_token"]
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
warn "Token refresh failed: #{e.message}"
|
|
77
|
+
authorize
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def discover_auth_server
|
|
81
|
+
resource_uri = URI.parse(@resource_url)
|
|
82
|
+
metadata_url = "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
|
|
83
|
+
|
|
84
|
+
resource_metadata = fetch_json(metadata_url)
|
|
85
|
+
auth_server_url = resource_metadata["authorization_servers"]&.first
|
|
86
|
+
raise "No authorization server found" unless auth_server_url
|
|
87
|
+
|
|
88
|
+
auth_metadata_url = "#{auth_server_url}/.well-known/oauth-authorization-server"
|
|
89
|
+
fetch_json(auth_metadata_url)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def load_or_register_client(metadata)
|
|
93
|
+
stored = @storage.load_client(@resource_url)
|
|
94
|
+
return stored if stored
|
|
95
|
+
|
|
96
|
+
client = register_client(metadata)
|
|
97
|
+
@storage.save_client(@resource_url, client)
|
|
98
|
+
@storage.load_client(@resource_url)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def register_client(metadata)
|
|
102
|
+
endpoint = metadata["registration_endpoint"]
|
|
103
|
+
raise "Dynamic registration not supported" unless endpoint
|
|
104
|
+
|
|
105
|
+
body = {
|
|
106
|
+
client_name: "elelem",
|
|
107
|
+
redirect_uris: [REDIRECT_URI],
|
|
108
|
+
grant_types: %w[authorization_code refresh_token],
|
|
109
|
+
response_types: ["code"],
|
|
110
|
+
token_endpoint_auth_method: "none"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
response = post_json(endpoint, body)
|
|
114
|
+
JSON.parse(response.body)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def generate_pkce
|
|
118
|
+
verifier = SecureRandom.urlsafe_base64(32)
|
|
119
|
+
challenge = Base64.urlsafe_encode64(
|
|
120
|
+
Digest::SHA256.digest(verifier),
|
|
121
|
+
padding: false
|
|
122
|
+
)
|
|
123
|
+
[verifier, challenge]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_auth_url(metadata, client, challenge, state)
|
|
127
|
+
params = {
|
|
128
|
+
response_type: "code",
|
|
129
|
+
client_id: client[:client_id],
|
|
130
|
+
redirect_uri: REDIRECT_URI,
|
|
131
|
+
scope: metadata["scopes_supported"]&.join(" ") || "openid",
|
|
132
|
+
state: state,
|
|
133
|
+
code_challenge: challenge,
|
|
134
|
+
code_challenge_method: "S256"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
"#{metadata["authorization_endpoint"]}?#{URI.encode_www_form(params)}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def open_browser(url)
|
|
141
|
+
commands = ["xdg-open", "open", "start"]
|
|
142
|
+
commands.each do |cmd|
|
|
143
|
+
return if system(cmd, url, out: File::NULL, err: File::NULL)
|
|
144
|
+
end
|
|
145
|
+
warn "Open this URL in your browser: #{url}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def wait_for_callback(expected_state)
|
|
149
|
+
code = nil
|
|
150
|
+
@server = WEBrick::HTTPServer.new(
|
|
151
|
+
Port: CALLBACK_PORT,
|
|
152
|
+
Logger: WEBrick::Log.new(File::NULL),
|
|
153
|
+
AccessLog: []
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
at_exit { @server&.shutdown }
|
|
157
|
+
|
|
158
|
+
@server.mount_proc("/callback") do |req, res|
|
|
159
|
+
state = req.query["state"]
|
|
160
|
+
raise "State mismatch" unless state == expected_state
|
|
161
|
+
|
|
162
|
+
code = req.query["code"]
|
|
163
|
+
res.content_type = "text/html"
|
|
164
|
+
res.body = "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
|
|
165
|
+
@server.shutdown
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Timeout.timeout(120) { @server.start }
|
|
169
|
+
code
|
|
170
|
+
rescue Timeout::Error
|
|
171
|
+
@server.shutdown
|
|
172
|
+
raise "OAuth callback timed out"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def exchange_code(metadata, client, code, verifier)
|
|
176
|
+
uri = URI.parse(metadata["token_endpoint"])
|
|
177
|
+
|
|
178
|
+
body = {
|
|
179
|
+
grant_type: "authorization_code",
|
|
180
|
+
code: code,
|
|
181
|
+
redirect_uri: REDIRECT_URI,
|
|
182
|
+
client_id: client[:client_id],
|
|
183
|
+
code_verifier: verifier
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
response = post_form(uri, body)
|
|
187
|
+
JSON.parse(response.body)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def fetch_json(url)
|
|
191
|
+
response = nil
|
|
192
|
+
@http.get(url) { |r| response = r }
|
|
193
|
+
JSON.parse(response.body)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def post_json(url, body)
|
|
197
|
+
response = nil
|
|
198
|
+
@http.post(
|
|
199
|
+
url,
|
|
200
|
+
headers: { "Content-Type" => "application/json" },
|
|
201
|
+
body: body.to_json
|
|
202
|
+
) { |r| response = r }
|
|
203
|
+
response
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def post_form(uri, body)
|
|
207
|
+
response = nil
|
|
208
|
+
@http.post(
|
|
209
|
+
uri.to_s,
|
|
210
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
211
|
+
body: URI.encode_www_form(body)
|
|
212
|
+
) { |r| response = r }
|
|
213
|
+
response
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class MCP
|
|
5
|
+
class TokenStorage
|
|
6
|
+
STORAGE_DIR = File.expand_path("~/.config/elelem/tokens")
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
FileUtils.mkdir_p(STORAGE_DIR, mode: 0o700)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def save(resource_url, access_token:, refresh_token: nil, expires_in: nil)
|
|
13
|
+
data = {
|
|
14
|
+
access_token: access_token,
|
|
15
|
+
refresh_token: refresh_token,
|
|
16
|
+
expires_at: expires_in ? Time.now.to_i + expires_in : nil
|
|
17
|
+
}
|
|
18
|
+
path = token_path(resource_url)
|
|
19
|
+
File.write(path, data.to_json)
|
|
20
|
+
File.chmod(0o600, path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def load(resource_url)
|
|
24
|
+
path = token_path(resource_url)
|
|
25
|
+
return nil unless File.exist?(path)
|
|
26
|
+
|
|
27
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def save_client(resource_url, client_data)
|
|
33
|
+
path = client_path(resource_url)
|
|
34
|
+
File.write(path, client_data.to_json)
|
|
35
|
+
File.chmod(0o600, path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def load_client(resource_url)
|
|
39
|
+
path = client_path(resource_url)
|
|
40
|
+
return nil unless File.exist?(path)
|
|
41
|
+
|
|
42
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def token_path(resource_url)
|
|
50
|
+
hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
|
|
51
|
+
File.join(STORAGE_DIR, "#{hash}.json")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def client_path(resource_url)
|
|
55
|
+
hash = Digest::SHA256.hexdigest(resource_url)[0, 16]
|
|
56
|
+
File.join(STORAGE_DIR, "#{hash}_client.json")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/elelem/mcp.rb
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "mcp/token_storage"
|
|
4
|
+
require_relative "mcp/oauth"
|
|
5
|
+
|
|
3
6
|
module Elelem
|
|
7
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/server/tools.md
|
|
4
8
|
class MCP
|
|
5
|
-
|
|
6
|
-
|
|
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)
|
|
7
16
|
@servers = {}
|
|
8
17
|
end
|
|
9
18
|
|
|
@@ -29,44 +38,83 @@ module Elelem
|
|
|
29
38
|
|
|
30
39
|
private
|
|
31
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
|
+
|
|
32
52
|
def server(name)
|
|
33
|
-
@servers[name] ||=
|
|
53
|
+
@servers[name] ||= build_server(@config.dig("mcpServers", name))
|
|
34
54
|
end
|
|
35
55
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
initialize!
|
|
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))
|
|
42
61
|
end
|
|
62
|
+
end
|
|
43
63
|
|
|
64
|
+
module ServerInterface
|
|
44
65
|
def tools
|
|
45
66
|
request("tools/list")["tools"]
|
|
46
67
|
end
|
|
47
68
|
|
|
48
69
|
def call(name, args)
|
|
49
70
|
result = request("tools/call", { name: name, arguments: args })
|
|
50
|
-
{
|
|
71
|
+
logger.info({ tool: name, args: args, result: result }.to_json)
|
|
72
|
+
content = extract_content(result)
|
|
73
|
+
result["isError"] ? { error: content } : { content: content }
|
|
51
74
|
end
|
|
52
75
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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"))
|
|
58
86
|
end
|
|
59
87
|
|
|
60
88
|
private
|
|
61
89
|
|
|
62
|
-
def
|
|
90
|
+
def handshake!
|
|
63
91
|
request("initialize", {
|
|
64
|
-
protocolVersion: "
|
|
92
|
+
protocolVersion: "2025-06-18",
|
|
65
93
|
capabilities: {},
|
|
66
94
|
clientInfo: { name: "elelem", version: VERSION }
|
|
67
95
|
})
|
|
68
96
|
notify("notifications/initialized")
|
|
69
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
|
|
70
118
|
|
|
71
119
|
def request(method, params = {})
|
|
72
120
|
send_msg(id: @id += 1, method: method, params: params)
|
|
@@ -92,5 +140,104 @@ module Elelem
|
|
|
92
140
|
end
|
|
93
141
|
end
|
|
94
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
|
|
95
242
|
end
|
|
96
243
|
end
|
data/lib/elelem/net/claude.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Elelem
|
|
|
38
38
|
handle_event(event, tool_calls, &block)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
finalize_tool_calls(tool_calls)
|
|
41
|
+
finalize_tool_calls(tool_calls, &block)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
private
|
|
@@ -72,19 +72,21 @@ module Elelem
|
|
|
72
72
|
|
|
73
73
|
case delta["type"]
|
|
74
74
|
when "text_delta"
|
|
75
|
-
block.call(
|
|
75
|
+
block.call(type: "saying", text: delta["text"])
|
|
76
76
|
when "thinking_delta"
|
|
77
|
-
block.call(
|
|
77
|
+
block.call(type: "thinking", text: delta["thinking"])
|
|
78
78
|
when "input_json_delta"
|
|
79
79
|
tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def finalize_tool_calls(tool_calls)
|
|
83
|
+
def finalize_tool_calls(tool_calls, &block)
|
|
84
84
|
tool_calls.each do |tool_call|
|
|
85
85
|
args = tool_call.delete(:args)
|
|
86
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])
|
|
87
88
|
end
|
|
89
|
+
tool_calls
|
|
88
90
|
end
|
|
89
91
|
|
|
90
92
|
def stream(messages, system_prompt, tools)
|
data/lib/elelem/net/ollama.rb
CHANGED
|
@@ -35,11 +35,14 @@ module Elelem
|
|
|
35
35
|
message = event["message"] || {}
|
|
36
36
|
|
|
37
37
|
unless event["done"]
|
|
38
|
-
block.call(
|
|
38
|
+
block.call(type: "saying", text: message["content"]) if message["content"]
|
|
39
|
+
block.call(type: "thinking", text: message["thinking"]) if message["thinking"]
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
if message["tool_calls"]
|
|
42
|
-
|
|
43
|
+
parsed = parse_tool_calls(message["tool_calls"])
|
|
44
|
+
parsed.each { |tc| block.call(type: "tool_call", **tc) }
|
|
45
|
+
tool_calls.concat(parsed)
|
|
43
46
|
end
|
|
44
47
|
end
|
|
45
48
|
|
data/lib/elelem/net/openai.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Elelem
|
|
|
18
18
|
handle_event(event, tool_calls, &block)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
finalize_tool_calls(tool_calls)
|
|
21
|
+
finalize_tool_calls(tool_calls, &block)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
@@ -30,7 +30,7 @@ module Elelem
|
|
|
30
30
|
def handle_event(event, tool_calls, &block)
|
|
31
31
|
delta = event.dig("choices", 0, "delta") || {}
|
|
32
32
|
|
|
33
|
-
block.call(
|
|
33
|
+
block.call(type: "saying", text: delta["content"]) if delta["content"]
|
|
34
34
|
|
|
35
35
|
accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
|
|
36
36
|
end
|
|
@@ -72,13 +72,15 @@ module Elelem
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
def finalize_tool_calls(tool_calls)
|
|
75
|
+
def finalize_tool_calls(tool_calls, &block)
|
|
76
76
|
tool_calls.values.map do |tool_call|
|
|
77
|
-
{
|
|
77
|
+
result = {
|
|
78
78
|
id: tool_call[:id],
|
|
79
79
|
name: tool_call[:name],
|
|
80
80
|
arguments: JSON.parse(tool_call[:args])
|
|
81
81
|
}
|
|
82
|
+
block.call(type: "tool_call", **result)
|
|
83
|
+
result
|
|
82
84
|
end
|
|
83
85
|
end
|
|
84
86
|
end
|
data/lib/elelem/net.rb
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
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
|