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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Elelem
6
+ class Input
7
+ def initialize(commands: [])
8
+ @commands = commands
9
+ Reline.autocompletion = true
10
+ Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
11
+ end
12
+
13
+ def ask(prompt)
14
+ Reline.readline(prompt, true)&.strip
15
+ end
16
+
17
+ def interactive?
18
+ $stdin.tty?
19
+ end
20
+
21
+ private
22
+
23
+ def complete(target, preposing)
24
+ line = "#{preposing}#{target}"
25
+
26
+ if line.start_with?("/") && !preposing.include?(" ")
27
+ return command_names.select { |c| c.start_with?(line) }
28
+ end
29
+
30
+ if preposing.start_with?("/") && preposing.include?(" ")
31
+ cmd_name = preposing.delete_prefix("/").split(" ", 2).first
32
+ return complete_command_args(cmd_name, target)
33
+ end
34
+
35
+ complete_files(target)
36
+ end
37
+
38
+ def command_names
39
+ @commands.respond_to?(:names) ? @commands.names : @commands
40
+ end
41
+
42
+ def complete_command_args(cmd_name, partial)
43
+ return [] unless @commands.respond_to?(:completions_for)
44
+
45
+ @commands.completions_for(cmd_name, partial)
46
+ end
47
+
48
+ def complete_files(target)
49
+ output, = Open3.capture2("bash", "-c", "compgen -f #{Shellwords.escape(target)}")
50
+ output.lines.map(&:strip).first(20)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class NullInput
5
+ def ask(_prompt) = nil
6
+ def interactive? = false
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class NullOutput < Output
5
+ def say(*, **) = nil
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class NullTool
5
+ def initialize(name, available: [])
6
+ @name = name
7
+ @available = available
8
+ end
9
+
10
+ def name
11
+ "#{@name}?"
12
+ end
13
+
14
+ def validate(_args)
15
+ ["unknown tool: #{@name.inspect}. Available: #{@available.join(", ")}."]
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Output
5
+ def initialize(stream: $stdout, **)
6
+ @stream = stream
7
+ end
8
+
9
+ def say(text, as: nil)
10
+ return if blank?(text)
11
+
12
+ write "#{render(text, as)}\n"
13
+ end
14
+
15
+ def print(text)
16
+ return if blank?(text)
17
+
18
+ write text
19
+ end
20
+
21
+ def thinking(text)
22
+ print("\e[2;3m#{text}\e[0m") unless blank?(text)
23
+ end
24
+
25
+ def doing(name, args, state: "+")
26
+ say "#{state} \e[36m#{name}\e[0m(#{args})"
27
+ end
28
+
29
+ def waiting
30
+ end
31
+
32
+ def display_file(path, fallback: nil)
33
+ say(fallback || path)
34
+ end
35
+
36
+ private
37
+
38
+ def render(text, as)
39
+ case as
40
+ when :error
41
+ return "error: #{text}"
42
+ when :markdown
43
+ "```\n" + text + "\n```\n"
44
+ else
45
+ text
46
+ end
47
+ end
48
+
49
+ def write(text)
50
+ @stream.print text
51
+ end
52
+
53
+ def blank?(text)
54
+ text.nil? || text.to_s.strip.empty?
55
+ end
56
+ end
57
+ end
@@ -1,26 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- module Plugins
4
+ class Plugins
5
5
  LOAD_PATHS = [
6
- File.expand_path("plugins", __dir__),
7
- "~/.elelem/plugins",
8
- ".elelem/plugins"
6
+ "~/.agents/plugins",
7
+ ".agents/plugins"
9
8
  ].freeze
10
9
 
11
- def self.setup!(toolbox)
12
- load_plugins
13
- registry.each_value { |plugin| plugin.call(toolbox) }
10
+ def initialize(load_paths: LOAD_PATHS)
11
+ @loaded = false
12
+ @load_paths = load_paths
14
13
  end
15
14
 
16
- def self.reload!(toolbox)
17
- @registry = {}
18
- load_plugins
19
- registry.each_value { |plugin| plugin.call(toolbox) }
20
- end
15
+ def load!(force: false)
16
+ return if @loaded && !force
21
17
 
22
- def self.load_plugins
23
- LOAD_PATHS.each do |path|
18
+ @load_paths.each do |path|
24
19
  dir = File.expand_path(path)
25
20
  next unless File.directory?(dir)
26
21
 
@@ -30,14 +25,8 @@ module Elelem
30
25
  warn "elelem: failed to load plugin #{file}: #{e.message}"
31
26
  end
32
27
  end
33
- end
34
-
35
- def self.register(name, &block)
36
- (@registry ||= {})[name] = block
37
- end
38
28
 
39
- def self.registry
40
- @registry ||= {}
29
+ @loaded = true
41
30
  end
42
31
  end
43
32
  end
@@ -0,0 +1,3 @@
1
+ You are a terminal coding agent.
2
+
3
+ <%= agents_md %>
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Registration
5
+ attr_reader :providers, :setups, :factories
6
+
7
+ def initialize
8
+ @providers = {}
9
+ @setups = {}
10
+ @factories = {}
11
+ end
12
+
13
+ def provider(name, &factory)
14
+ @providers[name.to_s] = factory
15
+ self
16
+ end
17
+
18
+ def setup(name, &block)
19
+ @setups[name.to_s] = block
20
+ self
21
+ end
22
+
23
+ def input(&factory)
24
+ @factories[:input] = factory
25
+ self
26
+ end
27
+
28
+ def output(&factory)
29
+ @factories[:output] = factory
30
+ self
31
+ end
32
+
33
+ def clear_setups!
34
+ @setups.clear
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Registry
5
+ def initialize(plugins: Plugins.new)
6
+ @registration = Registration.new
7
+ @plugins = plugins
8
+ end
9
+
10
+ def configure
11
+ yield @registration
12
+ self
13
+ end
14
+
15
+ def build_provider(name)
16
+ @plugins.load!
17
+ @registration.providers.fetch(name.to_s) { require_gem(name) }.call
18
+ end
19
+
20
+ def apply(agent)
21
+ @plugins.load!
22
+ @registration.setups.each_value { |block| block.call(agent) }
23
+ end
24
+
25
+ def reload(agent)
26
+ @registration.clear_setups!
27
+ agent.toolbox.clear!
28
+ agent.commands.clear!
29
+ @plugins.load!(force: true)
30
+ apply(agent)
31
+ end
32
+
33
+ def names
34
+ @registration.providers.keys
35
+ end
36
+
37
+ def build_agent(provider, toolbox: Toolbox.new, input: nil, output: nil)
38
+ commands = Commands.new
39
+ agent = Agent.new(
40
+ build_provider(provider),
41
+ toolbox: toolbox,
42
+ commands: commands,
43
+ input: input || build_input(commands: commands),
44
+ output: output || build_output,
45
+ )
46
+ apply(agent)
47
+ agent
48
+ end
49
+
50
+ private
51
+
52
+ def build_input(**opts)
53
+ @plugins.load!
54
+ (@registration.factories[:input] || ->(**o) { Input.new(**o) }).call(**opts)
55
+ end
56
+
57
+ def build_output(**opts)
58
+ @plugins.load!
59
+ (@registration.factories[:output] || ->(**o) { Output.new(**o) }).call(**opts)
60
+ end
61
+
62
+ def require_gem(name)
63
+ require "elelem/#{name}"
64
+ @registration.providers.fetch(name.to_s) { raise "elelem-#{name} did not register a provider named #{name.inspect}" }
65
+ rescue LoadError
66
+ raise "unknown provider: #{name.inspect}. Available: #{names.join(", ")}. Run: gem install elelem-#{name}"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Elelem
6
+ class Result
7
+ extend Forwardable
8
+ def_delegators :@payload, :[], :each, :to_json, :to_h, :key?, :merge
9
+
10
+ def self.success(payload)
11
+ new(payload.merge(ok: true))
12
+ end
13
+
14
+ def self.failure(payload)
15
+ new(payload.merge(ok: false))
16
+ end
17
+
18
+ def initialize(payload)
19
+ @payload = payload
20
+ end
21
+
22
+ def ok? = !!@payload[:ok]
23
+ def error? = !!@payload[:error]
24
+ def error = @payload[:error]
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class SlashCommand
5
+ attr_reader :name, :description, :completions
6
+
7
+ def initialize(name, description = "", completions = nil, &handler)
8
+ @name = name
9
+ @description = description
10
+ @completions = completions
11
+ @handler = handler
12
+ end
13
+
14
+ def call(args)
15
+ @handler.call(args)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class StubProvider
5
+ def fetch(messages, tools = [], &block)
6
+ block.call(type: "thinking", text: "considering the request")
7
+ return block.call(type: "saying", text: "done.") if messages.last[:role] == "tool"
8
+
9
+ block.call(type: "saying", text: "let me check.")
10
+ return unless (tool = tools.first)
11
+
12
+ block.call(type: "doing", id: "stub-1", name: tool[:function][:name], arguments: {})
13
+ end
14
+ end
15
+ end
@@ -2,12 +2,12 @@
2
2
 
3
3
  module Elelem
4
4
  class SystemPrompt
5
- TEMPLATE_PATH = File.expand_path("templates/system_prompt.erb", __dir__)
5
+ DEFAULT = File.read(File.expand_path("prompts/default.erb", __dir__)).freeze
6
6
 
7
- attr_reader :memory
7
+ attr_accessor :template
8
8
 
9
- def initialize(memory: nil)
10
- @memory = memory
9
+ def initialize(template = nil)
10
+ @template = template || DEFAULT
11
11
  end
12
12
 
13
13
  def render
@@ -16,44 +16,6 @@ module Elelem
16
16
 
17
17
  private
18
18
 
19
- def template
20
- File.read(TEMPLATE_PATH)
21
- end
22
-
23
- def pwd
24
- Dir.pwd
25
- end
26
-
27
- def elelem_source
28
- File.expand_path("../..", __dir__)
29
- end
30
-
31
- def platform
32
- RUBY_PLATFORM.split("-").last
33
- end
34
-
35
- def date
36
- Date.today
37
- end
38
-
39
- def git_branch
40
- return unless File.exist?(".git")
41
-
42
- "branch: #{`git branch --show-current`.strip}"
43
- rescue
44
- nil
45
- end
46
-
47
- def repo_map
48
- `ctags -x --sort=no --languages=Ruby,Python,JavaScript,TypeScript,Go,Rust -R . 2>/dev/null`
49
- .lines
50
- .reject { |l| l.include?("vendor/") || l.include?("node_modules/") || l.include?("spec/") }
51
- .first(100)
52
- .join
53
- rescue
54
- ""
55
- end
56
-
57
19
  def agents_md
58
20
  Pathname.pwd.ascend.each do |dir|
59
21
  file = dir / "AGENTS.md"
data/lib/elelem/tool.rb CHANGED
@@ -7,11 +7,12 @@ module Elelem
7
7
  def initialize(name, description:, params: {}, required: [], aliases: [], &fn)
8
8
  @name = name
9
9
  @description = description
10
- @params = params
11
- @required = required
12
- @aliases = aliases
10
+ @params = params.freeze
11
+ @required = required.freeze
12
+ @aliases = aliases.freeze
13
13
  @fn = fn
14
- @schema = JSONSchemer.schema(schema_hash)
14
+ @schema_hash = { type: "object", properties: @params, required: @required }.freeze
15
+ @schema = JSONSchemer.schema(@schema_hash)
15
16
  end
16
17
 
17
18
  def call(args)
@@ -30,19 +31,10 @@ module Elelem
30
31
  function: {
31
32
  name: name,
32
33
  description: description,
33
- parameters: schema_hash
34
+ parameters: @schema_hash
34
35
  }
35
36
  }
36
37
  end
37
38
 
38
- private
39
-
40
- def schema_hash
41
- {
42
- type: "object",
43
- properties: params,
44
- required: required
45
- }
46
- end
47
39
  end
48
40
  end
@@ -10,38 +10,41 @@ module Elelem
10
10
  @hooks = { before: Hash.new { |h, k| h[k] = [] }, after: Hash.new { |h, k| h[k] = [] } }
11
11
  end
12
12
 
13
+ def clear!
14
+ @tools.clear
15
+ @aliases.clear
16
+ @hooks[:before].clear
17
+ @hooks[:after].clear
18
+ end
19
+
13
20
  def add(name, description:, params: {}, required: [], aliases: [], &fn)
14
21
  tool = Tool.new(name, description: description, params: params, required: required, aliases: aliases, &fn)
15
22
  @tools[name] = tool
16
23
  tool.aliases.each { |a| @aliases[a] = name }
17
24
  end
18
25
 
19
- def before(tool_name, &block)
26
+ def before(tool_name = :*, &block)
20
27
  @hooks[:before][tool_name] << block
21
28
  end
22
29
 
23
- def after(tool_name, &block)
30
+ def after(tool_name = :*, &block)
24
31
  @hooks[:after][tool_name] << block
25
32
  end
26
33
 
27
- def header(name, args, state: "+")
28
- name = tool_for(name)&.name || "#{name}?"
29
- "\n#{state} #{name}(#{args})"
34
+ def tool_for(name)
35
+ tools.fetch(@aliases.fetch(name, name)) { NullTool.new(name, available: tools.keys) }
30
36
  end
31
37
 
32
38
  def run(name, args)
33
39
  tool = tool_for(name)
34
- return failure(error: "unknown tool: #{name}. Use 'execute' to run shell commands like rg, fd, git.", tools: to_a) unless tool
35
-
36
40
  errors = tool.validate(args)
37
- return failure(error: errors.join(", ")) if errors.any?
41
+ return Result.failure(error: errors.join(", ")) if errors.any?
38
42
 
39
- @hooks[:before][tool.name].each { |h| h.call(args) }
40
- result = tool.call(args)
41
- @hooks[:after][tool.name].each { |h| h.call(args, result) }
42
- result[:error] ? failure(result) : success(result)
43
+ result = dispatch(tool, args)
44
+ result[:error] ? Result.failure(result) : Result.success(result)
43
45
  rescue => e
44
- failure(error: e.message, name: name, args: args)
46
+ Elelem.logger.warn("toolbox: #{e.message}\n#{e.backtrace.join("\n")}")
47
+ Result.failure(error: e.message, name: name, args: args)
45
48
  end
46
49
 
47
50
  def to_a
@@ -50,16 +53,13 @@ module Elelem
50
53
 
51
54
  private
52
55
 
53
- def tool_for(name)
54
- tools[@aliases.fetch(name, name)]
55
- end
56
-
57
- def success(payload)
58
- payload.merge(ok: true)
59
- end
60
-
61
- def failure(payload)
62
- payload.merge(ok: false)
56
+ def dispatch(tool, args)
57
+ @hooks[:before][:*].each { |h| h.call(args, tool_name: tool.name) }
58
+ @hooks[:before][tool.name].each { |h| h.call(args) }
59
+ result = tool.call(args)
60
+ @hooks[:after][:*].each { |h| h.call(args, result, tool_name: tool.name) }
61
+ @hooks[:after][tool.name].each { |h| h.call(args, result) }
62
+ result
63
63
  end
64
64
  end
65
65
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.9.2"
5
- end
4
+ VERSION = "0.11.0"
5
+ end
data/lib/elelem.rb CHANGED
@@ -1,50 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
3
  require "erb"
5
- require "fileutils"
4
+ require "forwardable"
6
5
  require "json"
7
6
  require "json_schemer"
7
+ require "logger"
8
8
  require "open3"
9
9
  require "pathname"
10
- require "reline"
11
- require "stringio"
12
- require "tempfile"
10
+ require "shellwords"
13
11
 
14
12
  require_relative "elelem/agent"
15
- require_relative "elelem/mcp"
16
- require_relative "elelem/net"
13
+ require_relative "elelem/slash_command"
14
+ require_relative "elelem/commands"
15
+ require_relative "elelem/conversation"
16
+ require_relative "elelem/input"
17
+ require_relative "elelem/null_input"
18
+ require_relative "elelem/output"
19
+ require_relative "elelem/null_output"
17
20
  require_relative "elelem/plugins"
21
+ require_relative "elelem/registration"
22
+ require_relative "elelem/registry"
23
+ require_relative "elelem/config"
24
+ require_relative "elelem/result"
25
+ require_relative "elelem/stub_provider"
18
26
  require_relative "elelem/system_prompt"
19
- require_relative "elelem/terminal"
20
27
  require_relative "elelem/tool"
28
+ require_relative "elelem/null_tool"
21
29
  require_relative "elelem/toolbox"
22
30
  require_relative "elelem/version"
23
31
 
24
32
  module Elelem
25
- def self.sh(cmd, args: [], cwd: Dir.pwd, env: {})
26
- output = StringIO.new
27
-
28
- Open3.popen2e(env, cmd, *args, chdir: cwd) do |stdin, out, wait_thr|
29
- stdin.close
30
- out.each_line do |line|
31
- yield line if block_given?
32
- output.write(line)
33
- end
33
+ def self.start(provider: "stub", toolbox: Toolbox.new)
34
+ self.build(provider: provider, toolbox: toolbox).repl
35
+ end
34
36
 
35
- { exit_status: wait_thr.value.exitstatus, content: output.string }
36
- end
37
+ def self.ask(prompt, provider: "stub", toolbox: Toolbox.new, output: NullOutput.new)
38
+ self.build(provider: provider, toolbox: toolbox, input: NullInput.new, output: output).turn(prompt)
37
39
  end
38
40
 
39
- def self.start(client, toolbox: Toolbox.new)
40
- Plugins.setup!(toolbox)
41
- Agent.new(client, toolbox).repl
41
+ def self.build(provider: "stub", toolbox: Toolbox.new, input: nil, output: nil)
42
+ Config.build_agent(provider, toolbox: toolbox, input: input, output: output)
42
43
  end
43
44
 
44
- def self.ask(client, prompt, toolbox: Toolbox.new)
45
- Plugins.setup!(toolbox)
46
- agent = Agent.new(client, toolbox, terminal: Terminal.new(quiet: true))
47
- agent.turn(prompt)
48
- agent.history.last[:content]
45
+ def self.logger
46
+ @logger ||= Logger.new($stderr).tap do |log|
47
+ log.level = Logger.const_get(ENV.fetch("ELELEM_LOG_LEVEL", "warn").upcase)
48
+ end
49
49
  end
50
50
  end
51
+
52
+ Elelem.configure { |config| config.provider(:stub) { Elelem::StubProvider.new } }