elelem 0.7.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.
@@ -0,0 +1,53 @@
1
+ Terminal coding agent. Be concise. Verify your work.
2
+
3
+ # Tools
4
+ - read(path): file contents
5
+ - write(path, content): create/overwrite file
6
+ - execute(command): shell command
7
+ - eval(ruby): execute Ruby code; use to create tools for repetitive tasks
8
+ - task(prompt): delegate complex searches or multi-file analysis to a focused subagent
9
+
10
+ # Editing
11
+ Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
12
+ Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
13
+ Use write for new files or full rewrites
14
+
15
+ # Search
16
+ Use execute(`rg`) for text search: `rg -n "pattern" .`
17
+ Use execute(`fd`) for file discovery: `fd -e rb .`
18
+ Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
19
+
20
+ # Task Management
21
+ For complex tasks:
22
+ 1. State plan before acting
23
+ 2. Work through steps one at a time
24
+ 3. Summarize what was done
25
+
26
+ # Long Tasks
27
+ For complex multi-step work, write notes to .elelem/scratch.md
28
+
29
+ # Policy
30
+ - Explain before non-trivial commands
31
+ - Verify changes (read file, run tests)
32
+ - No interactive flags (-i, -p)
33
+ - Use `man` when you need to understand how to execute a program
34
+
35
+ # Environment
36
+ pwd: <%= pwd %>
37
+ platform: <%= platform %>
38
+ date: <%= date %>
39
+ self (this agent's source): <%= elelem_source %>
40
+ <%= git_branch %>
41
+
42
+ # Codebase
43
+ <%= repo_map %>
44
+ <% if agents_md %>
45
+
46
+ # Project Instructions
47
+ <%= agents_md %>
48
+ <% end %>
49
+ <% if memory %>
50
+
51
+ # Earlier Context
52
+ <%= memory %>
53
+ <% end %>
@@ -2,58 +2,79 @@
2
2
 
3
3
  module Elelem
4
4
  class Terminal
5
- def initialize(commands: [], modes: [], providers: [], env_vars: [])
5
+ def initialize(commands: [], quiet: false)
6
6
  @commands = commands
7
- @modes = modes
8
- @providers = providers
9
- @env_vars = env_vars
10
- @spinner_thread = nil
11
- setup_completion
7
+ @quiet = quiet
8
+ @dots_thread = nil
9
+ setup_completion unless @quiet
12
10
  end
13
11
 
14
12
  def ask(prompt)
15
13
  Reline.readline(prompt, true)&.strip
16
14
  end
17
15
 
18
- def say(message)
19
- stop_spinner
20
- $stdout.puts message
16
+ def think(text)
17
+ return if blank?(text)
18
+
19
+ "\e[2;3m#{text}\e[0m"
21
20
  end
22
21
 
23
- def write(message)
24
- stop_spinner
25
- $stdout.print message
22
+ def markdown(text)
23
+ return if @quiet || blank?(text)
24
+
25
+ newline(n: 2)
26
+ width = $stdout.winsize[1] rescue 80
27
+ IO.popen(["glow", "-s", "dark", "-w", width.to_s, "-"], "r+") do |io|
28
+ io.write(text)
29
+ io.close_write
30
+ io.read
31
+ end
32
+ rescue Errno::ENOENT
33
+ text
34
+ end
35
+
36
+ def print(text)
37
+ return if @quiet || blank?(text)
38
+
39
+ stop_dots
40
+ $stdout.print text
41
+ end
42
+
43
+ def say(text)
44
+ return if @quiet || blank?(text)
45
+
46
+ stop_dots
47
+ $stdout.puts text
48
+ end
49
+
50
+ def newline(n: 1)
51
+ n.times { $stdout.puts("") }
26
52
  end
27
53
 
28
54
  def waiting
29
- @spinner_thread = Thread.new do
30
- frames = %w[| / - \\]
31
- i = 0
55
+ return if @quiet
56
+
57
+ @dots_thread = Thread.new do
32
58
  loop do
33
- $stdout.print "\r#{frames[i % frames.length]} "
59
+ $stdout.print "."
34
60
  $stdout.flush
35
- i += 1
36
61
  sleep 0.1
37
62
  end
38
63
  end
39
64
  end
40
65
 
41
- def select(question, options, &block)
42
- CLI::UI::Prompt.ask(question) do |handler|
43
- options.each do |option|
44
- handler.option(option) { |selected| block.call(selected) }
45
- end
46
- end
47
- end
48
-
49
66
  private
50
67
 
51
- def stop_spinner
52
- return unless @spinner_thread
68
+ def blank?(text)
69
+ text.nil? || text.strip.empty?
70
+ end
71
+
72
+ def stop_dots
73
+ return unless @dots_thread
53
74
 
54
- @spinner_thread.kill
55
- @spinner_thread = nil
56
- $stdout.print "\r \r"
75
+ @dots_thread.kill
76
+ @dots_thread = nil
77
+ newline
57
78
  end
58
79
 
59
80
  def setup_completion
@@ -63,45 +84,14 @@ module Elelem
63
84
 
64
85
  def complete(target, preposing)
65
86
  line = "#{preposing}#{target}"
87
+ return @commands.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
66
88
 
67
- if line.start_with?('/') && !preposing.include?(' ')
68
- return @commands.select { |c| c.start_with?(line) }
69
- end
70
-
71
- case preposing.strip
72
- when '/mode'
73
- @modes.select { |m| m.start_with?(target) }
74
- when '/provider'
75
- @providers.select { |p| p.start_with?(target) }
76
- when '/env'
77
- @env_vars.select { |v| v.start_with?(target) }
78
- when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
79
- subcommands = %w[show ls insert generate edit rm]
80
- matches = subcommands.select { |c| c.start_with?(target) }
81
- matches.any? ? matches : complete_pass_entries(target)
82
- when %r{^/env\s+\w+$}
83
- complete_commands(target)
84
- else
85
- complete_files(target)
86
- end
87
- end
88
-
89
- def complete_commands(target)
90
- result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
91
- result["stdout"].lines.map(&:strip).first(20)
89
+ complete_files(target)
92
90
  end
93
91
 
94
92
  def complete_files(target)
95
- result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
96
- result["stdout"].lines.map(&:strip).first(20)
97
- end
98
-
99
- def complete_pass_entries(target)
100
- store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
101
- result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
102
- result["stdout"].lines.map { |l|
103
- l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
104
- }.select { |e| e.start_with?(target) }.first(20)
93
+ result = Elelem.sh("bash", args: ["-c", "compgen -f #{target}"])
94
+ result[:content].lines.map(&:strip).first(20)
105
95
  end
106
96
  end
107
97
  end
data/lib/elelem/tool.rb CHANGED
@@ -2,49 +2,47 @@
2
2
 
3
3
  module Elelem
4
4
  class Tool
5
- attr_reader :name
5
+ attr_reader :name, :description, :params, :required, :aliases
6
6
 
7
- def initialize(schema, &block)
8
- @name = schema.dig(:function, :name)
9
- @schema = schema
10
- @block = block
7
+ def initialize(name, description:, params: {}, required: [], aliases: [], &fn)
8
+ @name = name
9
+ @description = description
10
+ @params = params
11
+ @required = required
12
+ @aliases = aliases
13
+ @fn = fn
14
+ @schema = JSONSchemer.schema(schema_hash)
11
15
  end
12
16
 
13
17
  def call(args)
14
- unless valid?(args)
15
- actual = args.keys
16
- expected = @schema.dig(:function, :parameters)
17
- return { error: "Invalid args for #{@name}.", actual: actual, expected: expected }
18
- end
19
-
20
- @block.call(args)
18
+ @fn.call(args)
21
19
  end
22
20
 
23
- def valid?(args)
24
- JSON::Validator.validate(@schema.dig(:function, :parameters), args)
21
+ def validate(args)
22
+ @schema.validate(args || {}).map do |error|
23
+ error["error"]
24
+ end
25
25
  end
26
26
 
27
27
  def to_h
28
- @schema&.to_h
28
+ {
29
+ type: "function",
30
+ function: {
31
+ name: name,
32
+ description: description,
33
+ parameters: schema_hash
34
+ }
35
+ }
29
36
  end
30
37
 
31
- class << self
32
- def build(name, description, properties, required = [])
33
- new({
34
- type: "function",
35
- function: {
36
- name: name,
37
- description: description,
38
- parameters: {
39
- type: "object",
40
- properties: properties,
41
- required: required
42
- }
43
- }
44
- }) do |args|
45
- yield args
46
- end
47
- end
38
+ private
39
+
40
+ def schema_hash
41
+ {
42
+ type: "object",
43
+ properties: params,
44
+ required: required
45
+ }
48
46
  end
49
47
  end
50
48
  end
@@ -2,103 +2,64 @@
2
2
 
3
3
  module Elelem
4
4
  class Toolbox
5
+ attr_reader :tools, :hooks, :aliases
5
6
 
6
- READ_TOOL = Tool.build("read", "Read complete contents of a file. Requires exact file path.", { path: { type: "string" } }, ["path"]) do |args|
7
- path = args["path"]
8
- full_path = Pathname.new(path).expand_path
9
- full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
10
- end
11
-
12
- EXEC_TOOL = Tool.build("exec", "Run shell commands. Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
13
- Elelem.shell.execute(
14
- args["cmd"],
15
- args: args["args"] || [],
16
- env: args["env"] || {},
17
- cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"],
18
- stdin: args["stdin"]
19
- )
7
+ def initialize
8
+ @tools = {}
9
+ @aliases = {}
10
+ @hooks = { before: Hash.new { |h, k| h[k] = [] }, after: Hash.new { |h, k| h[k] = [] } }
20
11
  end
21
12
 
22
- GREP_TOOL = Tool.build("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers.", { query: { type: "string" } }, ["query"]) do |args|
23
- Elelem.shell.execute("git", args: ["grep", "-nI", args["query"]])
13
+ def add(name, description:, params: {}, required: [], aliases: [], &fn)
14
+ tool = Tool.new(name, description: description, params: params, required: required, aliases: aliases, &fn)
15
+ @tools[name] = tool
16
+ tool.aliases.each { |a| @aliases[a] = name }
24
17
  end
25
18
 
26
- LIST_TOOL = Tool.build("list", "List all git-tracked files in the repository, optionally filtered by path.", { path: { type: "string" } }) do |args|
27
- Elelem.shell.execute("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
19
+ def before(tool_name, &block)
20
+ @hooks[:before][tool_name] << block
28
21
  end
29
22
 
30
- PATCH_TOOL = Tool.build( "patch", "Apply a unified diff patch via 'git apply'. Use for surgical edits to existing files.", { diff: { type: "string" } }, ["diff"]) do |args|
31
- Elelem.shell.execute("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
23
+ def after(tool_name, &block)
24
+ @hooks[:after][tool_name] << block
32
25
  end
33
26
 
34
- WRITE_TOOL = Tool.build("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"]) do |args|
35
- full_path = Pathname.new(args["path"]).expand_path
36
- FileUtils.mkdir_p(full_path.dirname)
37
- { bytes_written: full_path.write(args["content"]) }
27
+ def header(name, args, state: "+")
28
+ name = tool_for(name)&.name || "#{name}?"
29
+ "\n#{state} #{name}(#{args})"
38
30
  end
39
31
 
40
- TOOL_ALIASES = {
41
- "bash" => "exec",
42
- "execute" => "exec",
43
- "open" => "read",
44
- "search" => "grep",
45
- "sh" => "exec",
46
- }
47
-
48
- attr_reader :tools
49
-
50
- def initialize
51
- @tools_by_name = {}
52
- @tool_permissions = {}
53
- @tools = { read: [], write: [], execute: [] }
54
- add_tool(eval_tool(binding), :execute)
55
- add_tool(EXEC_TOOL, :execute)
56
- add_tool(GREP_TOOL, :read)
57
- add_tool(LIST_TOOL, :read)
58
- add_tool(PATCH_TOOL, :write)
59
- add_tool(READ_TOOL, :read)
60
- add_tool(WRITE_TOOL, :write)
61
- end
32
+ def run(name, args)
33
+ 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
62
35
 
63
- def add_tool(tool, permission)
64
- @tools[permission] << tool
65
- @tools_by_name[tool.name] = tool
66
- @tool_permissions[tool.name] = permission
67
- end
36
+ errors = tool.validate(args)
37
+ return failure(error: errors.join(", ")) if errors.any?
68
38
 
69
- def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
70
- add_tool(Tool.build(name, description, properties, required, &block), mode)
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
+ rescue => e
44
+ failure(error: e.message, name: name, args: args)
71
45
  end
72
46
 
73
- def tools_for(permissions)
74
- Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
47
+ def to_a
48
+ tools.values.map(&:to_h)
75
49
  end
76
50
 
77
- def run_tool(name, args, permissions: [])
78
- resolved_name = TOOL_ALIASES.fetch(name, name)
79
- tool = @tools_by_name[resolved_name]
80
- return { error: "Unknown tool", name: name, args: args } unless tool
81
-
82
- tool_permission = @tool_permissions[resolved_name]
83
- unless Array(permissions).include?(tool_permission)
84
- return { error: "Tool '#{resolved_name}' not available in current mode", name: name }
85
- end
51
+ private
86
52
 
87
- tool.call(args)
88
- rescue => error
89
- { error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
53
+ def tool_for(name)
54
+ tools[@aliases.fetch(name, name)]
90
55
  end
91
56
 
92
- def tool_schema(name)
93
- @tools_by_name[name]&.to_h
57
+ def success(payload)
58
+ payload.merge(ok: true)
94
59
  end
95
60
 
96
- private
97
-
98
- def eval_tool(target_binding)
99
- Tool.build("eval", "Evaluates Ruby code with full access to register new tools via the `register_tool(name, desc, properties, required, mode: :execute) { |args| ... }` method.", { ruby: { type: "string" } }, ["ruby"]) do |args|
100
- { result: target_binding.eval(args["ruby"]) }
101
- end
61
+ def failure(payload)
62
+ payload.merge(ok: false)
102
63
  end
103
64
  end
104
65
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/elelem.rb CHANGED
@@ -1,56 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cli/ui"
3
+ require "date"
4
4
  require "erb"
5
5
  require "fileutils"
6
6
  require "json"
7
- require "json-schema"
8
- require "logger"
9
- require "net/llm"
7
+ require "json_schemer"
10
8
  require "open3"
11
9
  require "pathname"
12
10
  require "reline"
13
- require "set"
14
- require "thor"
15
- require "timeout"
11
+ require "stringio"
12
+ require "tempfile"
16
13
 
17
14
  require_relative "elelem/agent"
18
- require_relative "elelem/application"
19
- require_relative "elelem/conversation"
20
- require_relative "elelem/git_context"
15
+ require_relative "elelem/mcp"
16
+ require_relative "elelem/net"
17
+ require_relative "elelem/plugins"
18
+ require_relative "elelem/system_prompt"
21
19
  require_relative "elelem/terminal"
22
20
  require_relative "elelem/tool"
23
21
  require_relative "elelem/toolbox"
24
22
  require_relative "elelem/version"
25
23
 
26
- Reline.input = $stdin
27
- Reline.output = $stdout
28
-
29
24
  module Elelem
30
- class Error < StandardError; end
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
31
34
 
32
- class Shell
33
- def execute(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
34
- cmd = command.is_a?(Array) ? command.first : command
35
- cmd_args = command.is_a?(Array) ? command[1..] + args : args
36
- stdout, stderr, status = Open3.capture3(
37
- env,
38
- cmd,
39
- *cmd_args,
40
- chdir: cwd,
41
- stdin_data: stdin
42
- )
43
- {
44
- "exit_status" => status.exitstatus,
45
- "stdout" => stdout.to_s,
46
- "stderr" => stderr.to_s
47
- }
35
+ { exit_status: wait_thr.value.exitstatus, content: output.string }
48
36
  end
49
37
  end
50
38
 
51
- class << self
52
- def shell
53
- @shell ||= Shell.new
54
- end
39
+ def self.start(client, toolbox: Toolbox.new)
40
+ Plugins.setup!(toolbox)
41
+ Agent.new(client, toolbox).repl
42
+ end
43
+
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]
55
49
  end
56
50
  end