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
@@ -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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Output
5
+ RENDERERS = {
6
+ error: ->(text) { "error: #{text}" },
7
+ markdown: ->(text) { "```\n#{text}\n```\n" }
8
+ }.freeze
9
+ private_constant :RENDERERS
10
+
11
+ def initialize(stream: $stdout, **)
12
+ @stream = stream
13
+ end
14
+
15
+ def say(text, as: nil)
16
+ return if blank?(text)
17
+
18
+ write "#{render(text, as)}\n"
19
+ end
20
+
21
+ def print(text)
22
+ return if blank?(text)
23
+
24
+ write text
25
+ end
26
+
27
+ def thinking(text)
28
+ print("\e[2;3m#{text}\e[0m") unless blank?(text)
29
+ end
30
+
31
+ def doing(name, args, state: "+")
32
+ say "#{state} \e[36m#{name}\e[0m(#{args})"
33
+ end
34
+
35
+ def waiting; end
36
+
37
+ def display_file(path, fallback: nil)
38
+ say(fallback || path)
39
+ end
40
+
41
+ private
42
+
43
+ def render(text, as)
44
+ RENDERERS[as]&.call(text) || text
45
+ end
46
+
47
+ def write(text)
48
+ @stream.print text
49
+ end
50
+
51
+ def blank?(text)
52
+ text.nil? || text.to_s.strip.empty?
53
+ end
54
+ end
55
+ end
@@ -1,43 +1,36 @@
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!(agent)
12
- load_plugins
13
- registry.each_value { |plugin| plugin.call(agent) }
10
+ def initialize(load_paths: LOAD_PATHS)
11
+ @loaded = false
12
+ @load_paths = load_paths
14
13
  end
15
14
 
16
- def self.reload!(agent)
17
- registry.clear
18
- load_plugins
19
- registry.each_value { |plugin| plugin.call(agent) }
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|
24
- dir = File.expand_path(path)
25
- next unless File.directory?(dir)
26
-
27
- Dir["#{dir}/*.rb"].sort.each do |file|
28
- load(file)
29
- rescue => e
30
- warn "elelem: failed to load plugin #{file}: #{e.message}"
31
- end
32
- end
18
+ @load_paths.each { |path| load_dir(File.expand_path(path)) }
19
+ @loaded = true
33
20
  end
34
21
 
35
- def self.register(name, &block)
36
- registry[name] = block
22
+ private
23
+
24
+ def load_dir(dir)
25
+ return unless File.directory?(dir)
26
+
27
+ Dir["#{dir}/*.rb"].each { |file| load_plugin(file) }
37
28
  end
38
29
 
39
- def self.registry
40
- @registry ||= {}
30
+ def load_plugin(file)
31
+ load(file)
32
+ rescue StandardError, ScriptError => e
33
+ warn "elelem: failed to load plugin #{file}: #{e.message}"
41
34
  end
42
35
  end
43
36
  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,68 @@
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(session)
21
+ @plugins.load!
22
+ @registration.setups.each_value { |block| block.call(session) }
23
+ end
24
+
25
+ def reload(session)
26
+ @registration.clear_setups!
27
+ session.toolbox.clear!
28
+ session.commands.clear!
29
+ @plugins.load!(force: true)
30
+ apply(session)
31
+ end
32
+
33
+ def names
34
+ @registration.providers.keys
35
+ end
36
+
37
+ def build_session(provider, toolbox: Toolbox.new, input: nil, output: nil)
38
+ commands = Commands.new
39
+ agent = Agent.new(build_provider(provider), toolbox: toolbox, output: output || build_output)
40
+ session = Session.new(agent, input: input || build_input(commands: commands), commands: commands)
41
+ apply(session)
42
+ session
43
+ end
44
+
45
+ private
46
+
47
+ def build_input(**)
48
+ @plugins.load!
49
+ (@registration.factories[:input] || ->(**o) { Input.new(**o) }).call(**)
50
+ end
51
+
52
+ def build_output(**)
53
+ @plugins.load!
54
+ (@registration.factories[:output] || ->(**o) { Output.new(**o) }).call(**)
55
+ end
56
+
57
+ def require_gem(name)
58
+ require "elelem/#{name}"
59
+ @registration.providers.fetch(name.to_s) { unregistered_provider(name) }
60
+ rescue LoadError
61
+ raise "unknown provider: #{name.inspect}. Available: #{names.join(", ")}. Run: gem install elelem-#{name}"
62
+ end
63
+
64
+ def unregistered_provider(name)
65
+ raise "elelem-#{name} did not register a provider named #{name.inspect}"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Repl
5
+ def initialize(session)
6
+ @session = session
7
+ end
8
+
9
+ def run
10
+ @session.output.say "elelem v#{VERSION}"
11
+ while (line = @session.input.ask("> "))
12
+ dispatch_line(line) unless line.empty?
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def dispatch_line(line)
19
+ line.start_with?("/") ? command(line) : @session.turn(line)
20
+ end
21
+
22
+ def command(line)
23
+ name, args = line.delete_prefix("/").split(" ", 2)
24
+ @session.commands.run(name, args) || @session.output.say(@session.commands.names.join(" "))
25
+ rescue StandardError, ScriptError => e
26
+ warn_and_say(e)
27
+ end
28
+
29
+ def warn_and_say(error)
30
+ Elelem.logger.warn("repl: #{error.message}")
31
+ @session.output.say(error.message, as: :error)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Elelem
6
+ class Result
7
+ extend Forwardable
8
+
9
+ def_delegators :@payload, :[], :each, :to_json, :to_h, :key?, :merge
10
+
11
+ def self.success(payload)
12
+ new(payload.merge(ok: true))
13
+ end
14
+
15
+ def self.failure(payload)
16
+ new(payload.merge(ok: false))
17
+ end
18
+
19
+ def initialize(payload)
20
+ @payload = payload
21
+ end
22
+
23
+ def ok? = !!@payload[:ok]
24
+ def error? = !!@payload[:error]
25
+ def error = @payload[:error]
26
+ end
27
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Schema
5
+ def initialize(params: {}, required: [])
6
+ @params = params.freeze
7
+ @required = required.freeze
8
+ @schema_hash = { type: "object", properties: @params, required: @required }.freeze
9
+ @json_schemer = JSONSchemer.schema(@schema_hash)
10
+ end
11
+
12
+ def to_h
13
+ @schema_hash
14
+ end
15
+
16
+ def validate(args)
17
+ @json_schemer.validate(args || {}).map { |error| error["error"] }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Session
5
+ extend Forwardable
6
+
7
+ attr_reader :agent, :input, :commands
8
+
9
+ def_delegators :@agent, :provider, :provider=, :toolbox, :output, :conversation, :context, :turn
10
+
11
+ def initialize(agent, input: NullInput.new, commands: Commands.new)
12
+ @agent = agent
13
+ @input = input
14
+ @commands = commands
15
+ end
16
+ end
17
+ 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,153 +2,30 @@
2
2
 
3
3
  module Elelem
4
4
  class SystemPrompt
5
- TEMPLATE = <<~ERB
6
- Terminal coding agent. Be concise. Verify your work.
5
+ DEFAULT = File.read(File.expand_path("prompts/default.erb", __dir__)).freeze
6
+ USER_AGENTS_MD = Pathname.new(Dir.home).join(".agents/AGENTS.md").freeze
7
7
 
8
- # Tools
9
- - read(path): file contents
10
- - write(path, content): create/overwrite file
11
- - execute(command): shell command
12
- - eval(ruby): execute Ruby code; use to create tools for repetitive tasks
13
- - task(prompt): delegate complex searches or multi-file analysis to a focused subagent
8
+ attr_accessor :template
14
9
 
15
- # Editing
16
- Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
17
- Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
18
- Use write for new files or full rewrites
19
-
20
- # Search
21
- Use execute(`rg`) for text search: `rg -n "pattern" .`
22
- Use execute(`fd`) for file discovery: `fd -e rb .`
23
- Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
24
-
25
- # Task Management
26
- For complex tasks:
27
- 1. State plan before acting
28
- 2. Work through steps one at a time
29
- 3. Summarize what was done
30
-
31
- # Long Tasks
32
- For complex multi-step work, write notes to .elelem/scratch.md
33
-
34
- # Policy
35
- - Explain before non-trivial commands
36
- - Verify changes (read file, run tests)
37
- - No interactive flags (-i, -p)
38
- - Use `man` when you need to understand how to execute a program
39
-
40
- # Environment
41
- pwd: <%= pwd %>
42
- platform: <%= platform %>
43
- date: <%= date %>
44
- self: <%= elelem_source %>
45
- <%= git_info %>
46
-
47
- <% if repo_map && !repo_map.empty? %>
48
- # Codebase
49
- ```
50
- <%= repo_map %>```
51
- <% end %>
52
- <%= agents_md %>
53
- ERB
10
+ def initialize(template = nil)
11
+ @template = template || DEFAULT
12
+ end
54
13
 
55
14
  def render
56
- ERB.new(TEMPLATE, trim_mode: "-").result(binding)
15
+ ERB.new(template, trim_mode: "-").result(binding)
57
16
  end
58
17
 
59
18
  private
60
19
 
61
- def pwd = Dir.pwd
62
- def platform = RUBY_PLATFORM.split("-").last
63
- def date = Date.today
64
-
65
- def elelem_source
66
- spec = Gem.loaded_specs["elelem"]
67
- spec ? spec.gem_dir : File.expand_path("../..", __dir__)
68
- end
69
-
70
- def git_info
71
- return unless File.exist?(".git")
72
- "branch: #{`git branch --show-current`.strip}"
73
- rescue Errno::ENOENT
74
- nil
75
- end
76
-
77
- def repo_map
78
- files = `git ls-files '*.rb' 2>/dev/null`.lines.map(&:strip)
79
- return "" if files.empty?
80
-
81
- symbols = extract_symbols(files)
82
- format_symbols(symbols, budget: 2000)
83
- end
84
-
85
- def extract_symbols(files)
86
- output, status = Open3.capture2("sg", "run", "-p", "def $NAME", "-l", "ruby", "--json=compact", ".", err: File::NULL)
87
- return ctags_fallback(files) unless status.success?
88
-
89
- parse_sg_output(output, files)
90
- end
91
-
92
- def parse_sg_output(output, tracked_files)
93
- JSON.parse(output).filter_map do |match|
94
- file = match["file"]
95
- next unless tracked_files.include?(file)
96
- { file: file, name: match.dig("metaVariables", "single", "NAME", "text") }
97
- end
98
- rescue JSON::ParserError
99
- []
100
- end
101
-
102
- def ctags_fallback(files)
103
- return [] if files.empty?
104
-
105
- output = IO.popen(["ctags", "-x", "--languages=Ruby", "--kinds-Ruby=cfm", "-L", "-"], "r+") do |io|
106
- io.puts(files)
107
- io.close_write
108
- io.read
109
- end
110
-
111
- output.lines.map do |line|
112
- parts = line.split(/\s+/, 4)
113
- { file: parts[2], name: parts[0] }
114
- end
115
- rescue Errno::ENOENT
116
- []
117
- end
118
-
119
- def format_symbols(symbols, budget:)
120
- tree = build_tree(symbols)
121
- render_tree(tree, budget: budget)
122
- end
123
-
124
- def build_tree(symbols)
125
- tree = {}
126
- symbols.group_by { |s| s[:file] }.each do |file, syms|
127
- parts = file.split("/")
128
- node = tree
129
- parts[0..-2].each { |dir| node = (node[dir + "/"] ||= {}) }
130
- node[parts.last] = syms.map { |s| s[:name] }.uniq
131
- end
132
- tree
20
+ def agents_md
21
+ [global_agents_md, project_agents_md].compact.then { |parts| parts.empty? ? nil : parts.join("\n\n") }
133
22
  end
134
23
 
135
- def render_tree(node, indent: 0, budget:, result: String.new)
136
- node.each do |key, value|
137
- if value.is_a?(Hash)
138
- line = " " * indent + key + "\n"
139
- return result if result.length + line.length > budget
140
- result << line
141
- render_tree(value, indent: indent + 1, budget: budget, result: result)
142
- else
143
- line = " " * indent + key.sub(/\.rb$/, "") + ": " + value.join(" ") + "\n"
144
- return result if result.length + line.length > budget
145
- result << line
146
- end
147
- end
148
- result
24
+ def global_agents_md
25
+ USER_AGENTS_MD.read if USER_AGENTS_MD.exist?
149
26
  end
150
27
 
151
- def agents_md
28
+ def project_agents_md
152
29
  Pathname.pwd.ascend.each do |dir|
153
30
  file = dir / "AGENTS.md"
154
31
  return file.read if file.exist?
data/lib/elelem/tool.rb CHANGED
@@ -2,27 +2,22 @@
2
2
 
3
3
  module Elelem
4
4
  class Tool
5
- attr_reader :name, :description, :params, :required, :aliases
5
+ attr_reader :name, :description, :aliases
6
6
 
7
- def initialize(name, description:, params: {}, required: [], aliases: [], &fn)
7
+ def initialize(name, description:, schema: Schema.new, aliases: [], &handler)
8
8
  @name = name
9
9
  @description = description
10
- @params = params.freeze
11
- @required = required.freeze
10
+ @schema = schema
12
11
  @aliases = aliases.freeze
13
- @fn = fn
14
- @schema_hash = { type: "object", properties: @params, required: @required }.freeze
15
- @schema = JSONSchemer.schema(@schema_hash)
12
+ @handler = handler
16
13
  end
17
14
 
18
15
  def call(args)
19
- @fn.call(args)
16
+ @handler.call(args)
20
17
  end
21
18
 
22
19
  def validate(args)
23
- @schema.validate(args || {}).map do |error|
24
- error["error"]
25
- end
20
+ @schema.validate(args)
26
21
  end
27
22
 
28
23
  def to_h
@@ -31,7 +26,7 @@ module Elelem
31
26
  function: {
32
27
  name: name,
33
28
  description: description,
34
- parameters: @schema_hash
29
+ parameters: @schema.to_h
35
30
  }
36
31
  }
37
32
  end