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.
data/lib/elelem/agent.rb CHANGED
@@ -2,277 +2,184 @@
2
2
 
3
3
  module Elelem
4
4
  class Agent
5
- PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
6
- ANTHROPIC_MODELS = %w[claude-sonnet-4-20250514 claude-opus-4-20250514 claude-haiku-3-5-20241022].freeze
7
- VERTEX_MODELS = %w[claude-sonnet-4@20250514 claude-opus-4-5@20251101].freeze
8
- COMMANDS = %w[/env /mode /provider /model /shell /clear /context /exit /help].freeze
9
- MODES = %w[auto build plan verify].freeze
10
- ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
11
-
12
- attr_reader :conversation, :client, :toolbox, :provider, :terminal, :permissions
13
-
14
- def initialize(provider, model, toolbox, terminal: nil)
15
- @conversation = Conversation.new
16
- @provider = provider
5
+ COMMANDS = %w[/clear /context /init /reload /shell /exit /help].freeze
6
+ MAX_CONTEXT_MESSAGES = 50
7
+ INIT_PROMPT = <<~PROMPT
8
+ AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
9
+
10
+ # AGENTS.md Spec (https://agents.md/)
11
+ A file providing context and instructions for AI coding agents.
12
+
13
+ ## Recommended Sections
14
+ - Commands: build, test, lint commands
15
+ - Code Style: conventions, patterns
16
+ - Architecture: key components and flow
17
+ - Testing: how to run tests
18
+
19
+ ## Process
20
+ 1. Read README.md if present
21
+ 2. Identify language (Gemfile, package.json, go.mod)
22
+ 3. Find test scripts (bin/test, npm test)
23
+ 4. Check linter configs
24
+ 5. Write concise AGENTS.md
25
+
26
+ Keep it minimal. No fluff.
27
+ PROMPT
28
+
29
+ attr_reader :history, :client, :toolbox, :terminal
30
+
31
+ def initialize(client, toolbox, terminal: nil, history: nil, system_prompt: nil)
32
+ @client = client
17
33
  @toolbox = toolbox
18
- @client = build_client(provider, model)
19
- @terminal = terminal || default_terminal
20
- @permissions = Set.new([:read])
34
+ @terminal = terminal || Terminal.new(commands: COMMANDS)
35
+ @history = history || []
36
+ @system_prompt = system_prompt
37
+ @memory = nil
38
+ register_task_tool
21
39
  end
22
40
 
23
41
  def repl
42
+ terminal.say "elelem v#{VERSION}"
24
43
  loop do
25
- input = terminal.ask("User> ")
44
+ input = terminal.ask("> ")
26
45
  break if input.nil?
27
- if input.start_with?("/")
28
- handle_slash_command(input)
29
- else
30
- conversation.add(role: :user, content: input)
31
- result = execute_turn(conversation.history_for(permissions))
32
- conversation.add(role: result[:role], content: result[:content])
33
- end
46
+ next if input.empty?
47
+ input.start_with?("/") ? command(input) : turn(input)
34
48
  end
35
49
  end
36
50
 
37
- private
38
-
39
- def default_terminal
40
- Terminal.new(
41
- commands: COMMANDS,
42
- env_vars: ENV_VARS,
43
- modes: MODES,
44
- providers: PROVIDERS
45
- )
46
- end
47
-
48
- def handle_slash_command(input)
51
+ def command(input)
49
52
  case input
50
- when "/mode auto"
51
- permissions.replace([:read, :write, :execute])
52
- terminal.say " Mode: auto (all tools enabled)"
53
- when "/mode build"
54
- permissions.replace([:read, :write])
55
- terminal.say " → Mode: build (read + write)"
56
- when "/mode plan"
57
- permissions.replace([:read])
58
- terminal.say " → Mode: plan (read-only)"
59
- when "/mode verify"
60
- permissions.replace([:read, :execute])
61
- terminal.say " → Mode: verify (read + execute)"
62
- when "/mode"
63
- terminal.say " Usage: /mode [auto|build|plan|verify]"
64
- terminal.say ""
65
- terminal.say " Provider: #{provider}/#{client.model}"
66
- terminal.say " Permissions: #{permissions.to_a.inspect}"
67
- terminal.say " Tools: #{toolbox.tools_for(permissions).map { |t| t.dig(:function, :name) }}"
68
- when "/exit" then exit
69
- when "/clear"
70
- conversation.clear
71
- terminal.say " → Conversation cleared"
72
- when "/context"
73
- terminal.say conversation.dump(permissions)
53
+ when "/exit" then exit(0)
54
+ when "/init" then init_agents_md
55
+ when "/reload" then reload_source!
74
56
  when "/shell"
75
57
  transcript = start_shell
76
- conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
77
- terminal.say " → Shell session captured"
78
- when "/provider"
79
- terminal.select("Provider?", PROVIDERS) do |selected_provider|
80
- terminal.select("Model?", models_for(selected_provider)) do |m|
81
- switch_client(selected_provider, m)
82
- end
83
- end
84
- when "/model"
85
- terminal.select("Model?", models_for(provider)) do |m|
86
- switch_model(m)
87
- end
88
- when "/env"
89
- terminal.say " Usage: /env VAR cmd..."
90
- terminal.say ""
91
- ENV_VARS.each do |var|
92
- value = ENV[var]
93
- if value
94
- masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
95
- terminal.say " #{var}=#{masked}"
96
- else
97
- terminal.say " #{var}=(not set)"
98
- end
99
- end
100
- when %r{^/env\s+(\w+)\s+(.+)$}
101
- var_name = $1
102
- command = $2
103
- result = Elelem.shell.execute("sh", args: ["-c", command])
104
- if result["exit_status"].zero?
105
- value = result["stdout"].lines.first&.strip
106
- if value && !value.empty?
107
- ENV[var_name] = value
108
- terminal.say " → Set #{var_name}"
109
- else
110
- terminal.say " ⚠ Command produced no output"
111
- end
112
- else
113
- terminal.say " ⚠ Command failed: #{result['stderr']}"
114
- end
58
+ history << { role: "user", content: transcript } unless transcript.strip.empty?
59
+ when "/clear"
60
+ @history = []
61
+ @memory = nil
62
+ terminal.say " context cleared"
63
+ when "/context"
64
+ terminal.say JSON.pretty_generate(combined_history)
115
65
  else
116
- terminal.say help_banner
66
+ terminal.say COMMANDS.join(" ")
117
67
  end
118
68
  end
119
69
 
120
- def strip_ansi(text)
121
- text.gsub(/^Script started.*?\n/, '')
122
- .gsub(/\nScript done.*$/, '')
123
- .gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
124
- .gsub(/\e\[\?[0-9]+[hl]/, '')
125
- .gsub(/[\b]/, '')
126
- .gsub(/\r/, '')
127
- end
70
+ def turn(input)
71
+ compact_if_needed
72
+ history << { role: "user", content: input }
73
+ ctx = []
74
+ content = nil
128
75
 
129
- def start_shell
130
- Tempfile.create do |file|
131
- system("script -q #{file.path}", chdir: Dir.pwd)
132
- strip_ansi(File.read(file.path))
76
+ loop do
77
+ terminal.waiting
78
+ content, tool_calls = fetch_response(ctx)
79
+ terminal.say(terminal.markdown(content))
80
+ break if tool_calls.empty?
81
+
82
+ ctx << { role: "assistant", content: content, tool_calls: tool_calls }.compact
83
+ tool_calls.each do |tool_call|
84
+ ctx << { role: "tool", tool_call_id: tool_call[:id], content: process(tool_call).to_json }
85
+ end
133
86
  end
134
- end
135
87
 
136
- def help_banner
137
- <<~HELP
138
- /env VAR cmd...
139
- /mode auto build plan verify
140
- /provider
141
- /model
142
- /shell
143
- /clear
144
- /context
145
- /exit
146
- /help
147
- HELP
88
+ history << { role: "assistant", content: content }
89
+ content
148
90
  end
149
91
 
150
- def build_client(provider_name, model = nil)
151
- model_opts = model ? { model: model } : {}
152
-
153
- case provider_name
154
- when "ollama" then Net::Llm::Ollama.new(**model_opts)
155
- when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
156
- when "openai" then Net::Llm::OpenAI.new(**model_opts)
157
- when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
158
- else
159
- raise Error, "Unknown provider: #{provider_name}"
160
- end
161
- end
92
+ private
162
93
 
163
- def models_for(provider_name)
164
- case provider_name
165
- when "ollama"
166
- client_for_models = provider_name == provider ? client : build_client(provider_name)
167
- client_for_models.tags["models"]&.map { |m| m["name"] } || []
168
- when "openai"
169
- client_for_models = provider_name == provider ? client : build_client(provider_name)
170
- client_for_models.models["data"]&.map { |m| m["id"] } || []
171
- when "anthropic"
172
- ANTHROPIC_MODELS
173
- when "vertex-ai"
174
- VERTEX_MODELS
175
- else
176
- []
94
+ def process(tool_call)
95
+ name, args = tool_call[:name], tool_call[:arguments]
96
+ terminal.say toolbox.header(name, args)
97
+ toolbox.run(name.to_s, args)
98
+ end
99
+
100
+ def register_task_tool
101
+ @toolbox.add("task",
102
+ description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
103
+ params: { prompt: { type: "string" } },
104
+ required: ["prompt"]
105
+ ) do |a|
106
+ sub = Agent.new(client, toolbox, terminal: terminal,
107
+ system_prompt: "Research agent. Search, analyze, report. Be concise.")
108
+ sub.turn(a["prompt"])
109
+ { result: sub.history.last[:content] }
177
110
  end
178
- rescue KeyError => e
179
- terminal.say " ⚠ Missing credentials: #{e.message}"
180
- []
181
- rescue => e
182
- terminal.say " ⚠ Could not fetch models: #{e.message}"
183
- []
184
111
  end
185
112
 
186
- def switch_client(new_provider, model)
187
- @provider = new_provider
188
- @client = build_client(new_provider, model)
189
- terminal.say " → Switched to #{new_provider}/#{client.model}"
113
+ def init_agents_md
114
+ sub = Agent.new(client, toolbox, terminal: terminal, system_prompt: INIT_PROMPT)
115
+ sub.turn("Generate AGENTS.md for this project")
190
116
  end
191
117
 
192
- def switch_model(model)
193
- @client = build_client(provider, model)
194
- terminal.say " → Switched to #{provider}/#{client.model}"
118
+ def reload_source!
119
+ lib_dir = File.expand_path("..", __dir__)
120
+ original_verbose, $VERBOSE = $VERBOSE, nil
121
+ Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
122
+ $VERBOSE = original_verbose
123
+ @toolbox = Toolbox.new
124
+ Plugins.reload!(@toolbox)
125
+ register_task_tool
195
126
  end
196
127
 
197
- def format_tool_call_result(result)
198
- return if result.nil?
199
- return result["stdout"] if result["stdout"]
200
- return result["stderr"] if result["stderr"]
201
- return result[:error] if result[:error]
202
-
203
- ""
128
+ def start_shell
129
+ Tempfile.create do |file|
130
+ system("script", "-q", file.path, chdir: Dir.pwd)
131
+ strip_ansi(File.read(file.path))
132
+ end
204
133
  end
205
134
 
206
- def truncate_output(text, max_lines: 30)
207
- return text if text.nil? || text.empty?
208
-
209
- lines = text.to_s.lines
210
- if lines.size > max_lines
211
- lines.first(max_lines).join + "\n... (#{lines.size - max_lines} more lines)"
212
- else
213
- text
135
+ def strip_ansi(text)
136
+ text.gsub(/^Script started.*?\n/, "")
137
+ .gsub(/\nScript done.*$/, "")
138
+ .gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
139
+ .gsub(/\e\[\?[0-9]+[hl]/, "")
140
+ .gsub(/[\b]/, "")
141
+ .gsub(/\r/, "")
142
+ end
143
+
144
+ def fetch_response(ctx)
145
+ content = ""
146
+ tool_calls = client.fetch(combined_history + ctx, toolbox.to_a) do |delta|
147
+ content += delta[:content].to_s
148
+ terminal.print(terminal.think(delta[:thinking])) if delta[:thinking]
214
149
  end
150
+ [content, tool_calls]
151
+ rescue => e
152
+ terminal.say "\n ✗ #{e.message}"
153
+ ["Error: #{e.message} #{e.backtrace.join("\n")}", []]
215
154
  end
216
155
 
217
- def format_tool_calls_for_api(tool_calls)
218
- tool_calls.map do |tc|
219
- args = openai_client? ? JSON.dump(tc[:arguments]) : tc[:arguments]
220
- {
221
- id: tc[:id],
222
- type: "function",
223
- function: { name: tc[:name], arguments: args }
224
- }
225
- end
156
+ def combined_history
157
+ [{ role: "system", content: system_prompt }] + history
226
158
  end
227
159
 
228
- def openai_client?
229
- client.is_a?(Net::Llm::OpenAI)
160
+ def system_prompt
161
+ @system_prompt || SystemPrompt.new(memory: @memory).render
230
162
  end
231
163
 
232
- def execute_turn(messages)
233
- tools = toolbox.tools_for(permissions)
234
- turn_context = []
235
- errors = 0
164
+ def compact_if_needed
165
+ return if history.length <= MAX_CONTEXT_MESSAGES
236
166
 
237
- loop do
238
- content = ""
239
- tool_calls = []
167
+ terminal.say " → compacting context"
168
+ keep = MAX_CONTEXT_MESSAGES / 2
169
+ old = history.first(history.length - keep)
240
170
 
241
- terminal.waiting
242
- begin
243
- client.fetch(messages + turn_context, tools) do |chunk|
244
- case chunk[:type]
245
- when :delta
246
- terminal.write chunk[:thinking] if chunk[:thinking]
247
- content += chunk[:content] if chunk[:content]
248
- when :complete
249
- content = chunk[:content] if chunk[:content]
250
- tool_calls = chunk[:tool_calls] || []
251
- end
252
- end
253
- rescue => e
254
- terminal.say "\n ✗ API Error: #{e.message}"
255
- return { role: "assistant", content: "[Error: #{e.message}]" }
256
- end
171
+ to_summarize = @memory ? [{ role: "memory", content: @memory }, *old] : old
172
+ @memory = summarize(to_summarize)
173
+ @history = history.last(keep)
174
+ end
257
175
 
258
- terminal.say "\nAssistant> #{content}" unless content.to_s.empty?
259
- api_tool_calls = tool_calls.any? ? format_tool_calls_for_api(tool_calls) : nil
260
- turn_context << { role: "assistant", content: content, tool_calls: api_tool_calls }.compact
176
+ def summarize(messages)
177
+ text = messages.map { |message| { role: message[:role], content: message[:content] } }.to_json
261
178
 
262
- if tool_calls.any?
263
- tool_calls.each do |call|
264
- name, args = call[:name], call[:arguments]
265
- terminal.say "\nTool> #{name}(#{args})"
266
- result = toolbox.run_tool(name, args, permissions: permissions)
267
- terminal.say truncate_output(format_tool_call_result(result))
268
- turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
269
- errors += 1 if result[:error]
270
- end
271
- return { role: "assistant", content: "[Stopped: too many errors]" } if errors >= 3
272
- next
179
+ String.new.tap do |buffer|
180
+ client.fetch([{ role: "user", content: "Summarize key facts:\n#{text}" }], []) do |d|
181
+ buffer << d[:content].to_s
273
182
  end
274
-
275
- return { role: "assistant", content: content }
276
183
  end
277
184
  end
278
185
  end
data/lib/elelem/mcp.rb ADDED
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class MCP
5
+ def initialize(config_path = ".mcp.json")
6
+ @config = File.exist?(config_path) ? JSON.parse(IO.read(config_path)) : {}
7
+ @servers = {}
8
+ end
9
+
10
+ def tools
11
+ @config.fetch("mcpServers", {}).flat_map do |name, _|
12
+ server(name).tools.map do |tool|
13
+ [
14
+ "#{name}_#{tool["name"]}",
15
+ {
16
+ description: tool["description"],
17
+ params: tool.dig("inputSchema", "properties") || {},
18
+ required: tool.dig("inputSchema", "required") || [],
19
+ fn: ->(a) { server(name).call(tool["name"], a) }
20
+ }
21
+ ]
22
+ end
23
+ end.to_h
24
+ end
25
+
26
+ def close
27
+ @servers.each_value(&:close)
28
+ end
29
+
30
+ private
31
+
32
+ def server(name)
33
+ @servers[name] ||= Server.new(**@config.dig("mcpServers", name).transform_keys(&:to_sym))
34
+ end
35
+
36
+ class Server
37
+ def initialize(command:, args: [], env: {})
38
+ resolved_env = env.transform_values { |v| v.gsub(/\$\{(\w+)\}/) { ENV[$1] } }
39
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
40
+ @id = 0
41
+ initialize!
42
+ end
43
+
44
+ def tools
45
+ request("tools/list")["tools"]
46
+ end
47
+
48
+ def call(name, args)
49
+ result = request("tools/call", { name: name, arguments: args })
50
+ { content: result["content"]&.map { |c| c["text"] }&.join("\n") }
51
+ end
52
+
53
+ def close
54
+ @stdin.close rescue nil
55
+ @stdout.close rescue nil
56
+ @stderr.close rescue nil
57
+ @wait.kill rescue nil
58
+ end
59
+
60
+ private
61
+
62
+ def initialize!
63
+ request("initialize", {
64
+ protocolVersion: "2024-11-05",
65
+ capabilities: {},
66
+ clientInfo: { name: "elelem", version: VERSION }
67
+ })
68
+ notify("notifications/initialized")
69
+ end
70
+
71
+ def request(method, params = {})
72
+ send_msg(id: @id += 1, method: method, params: params)
73
+ read_response(@id)
74
+ end
75
+
76
+ def notify(method, params = {})
77
+ send_msg(method: method, params: params)
78
+ end
79
+
80
+ def send_msg(msg)
81
+ @stdin.puts({ jsonrpc: "2.0", **msg }.to_json)
82
+ @stdin.flush
83
+ end
84
+
85
+ def read_response(id)
86
+ loop do
87
+ line = @stdout.gets
88
+ raise "Server closed" unless line
89
+ msg = JSON.parse(line)
90
+ return msg["result"] if msg["id"] == id
91
+ raise msg["error"]["message"] if msg["error"]
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end