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.
@@ -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,122 +2,64 @@
2
2
 
3
3
  module Elelem
4
4
  class Toolbox
5
- READ_TOOL = Tool.build("read", "Read complete contents of a file. Requires exact file path.", { path: { type: "string" } }, ["path"]) do |args|
6
- path = args["path"]
7
- full_path = Pathname.new(path).expand_path
8
- full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
9
- end
10
-
11
- 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|
12
- Elelem.shell.execute(
13
- args["cmd"],
14
- args: args["args"] || [],
15
- env: args["env"] || {},
16
- cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"],
17
- stdin: args["stdin"]
18
- )
19
- end
20
-
21
- 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|
22
- Elelem.shell.execute("git", args: ["grep", "-nI", args["query"]])
23
- end
5
+ attr_reader :tools, :hooks, :aliases
24
6
 
25
- LIST_TOOL = Tool.build("list", "List all git-tracked files in the repository, optionally filtered by path.", { path: { type: "string" } }) do |args|
26
- Elelem.shell.execute("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
7
+ def initialize
8
+ @tools = {}
9
+ @aliases = {}
10
+ @hooks = { before: Hash.new { |h, k| h[k] = [] }, after: Hash.new { |h, k| h[k] = [] } }
27
11
  end
28
12
 
29
- 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|
30
- Elelem.shell.execute("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
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 }
31
17
  end
32
18
 
33
- 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|
34
- full_path = Pathname.new(args["path"]).expand_path
35
- FileUtils.mkdir_p(full_path.dirname)
36
- { bytes_written: full_path.write(args["content"]) }
19
+ def before(tool_name, &block)
20
+ @hooks[:before][tool_name] << block
37
21
  end
38
22
 
39
- FETCH_TOOL = Tool.build("fetch", "Fetch content from a URL. Returns status, headers, and body.", { url: { type: "string", description: "The URL to fetch" } }, ["url"]) do |args|
40
- client = Net::Hippie::Client.new
41
- response = client.get(args["url"])
42
- { status: response.code.to_i, body: response.body }
23
+ def after(tool_name, &block)
24
+ @hooks[:after][tool_name] << block
43
25
  end
44
26
 
45
- WEB_SEARCH_TOOL = Tool.build("search_engine", "Search the web using DuckDuckGo. Returns raw API response.", { query: { type: "string", description: "The search query" } }, ["query"]) do |args|
46
- query = CGI.escape(args["query"])
47
- url = "https://api.duckduckgo.com/?q=#{query}&format=json&no_html=1"
48
- client = Net::Hippie::Client.new
49
- response = client.get(url)
50
- JSON.parse(response.body)
27
+ def header(name, args, state: "+")
28
+ name = tool_for(name)&.name || "#{name}?"
29
+ "\n#{state} #{name}(#{args})"
51
30
  end
52
31
 
53
- TOOL_ALIASES = {
54
- "bash" => "exec",
55
- "duckduckgo" => "search_engine",
56
- "ddg" => "search_engine",
57
- "execute" => "exec",
58
- "get" => "fetch",
59
- "open" => "read",
60
- "search" => "grep",
61
- "sh" => "exec",
62
- "web" => "fetch",
63
- }
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
64
35
 
65
- attr_reader :tools
36
+ errors = tool.validate(args)
37
+ return failure(error: errors.join(", ")) if errors.any?
66
38
 
67
- def initialize
68
- @tools_by_name = {}
69
- @tool_permissions = {}
70
- @tools = { read: [], write: [], execute: [] }
71
- add_tool(eval_tool(binding), :execute)
72
- add_tool(WEB_SEARCH_TOOL, :read)
73
- add_tool(EXEC_TOOL, :execute)
74
- add_tool(FETCH_TOOL, :read)
75
- add_tool(GREP_TOOL, :read)
76
- add_tool(LIST_TOOL, :read)
77
- add_tool(PATCH_TOOL, :write)
78
- add_tool(READ_TOOL, :read)
79
- add_tool(WRITE_TOOL, :write)
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)
80
45
  end
81
46
 
82
- def add_tool(tool, permission)
83
- @tools[permission] << tool
84
- @tools_by_name[tool.name] = tool
85
- @tool_permissions[tool.name] = permission
47
+ def to_a
48
+ tools.values.map(&:to_h)
86
49
  end
87
50
 
88
- def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
89
- add_tool(Tool.build(name, description, properties, required, &block), mode)
90
- end
91
-
92
- def tools_for(permissions)
93
- Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
94
- end
95
-
96
- def run_tool(name, args, permissions: [])
97
- resolved_name = TOOL_ALIASES.fetch(name, name)
98
- tool = @tools_by_name[resolved_name]
99
- return { error: "Unknown tool", name: name, args: args } unless tool
100
-
101
- tool_permission = @tool_permissions[resolved_name]
102
- unless Array(permissions).include?(tool_permission)
103
- return { error: "Tool '#{resolved_name}' not available in current mode", name: name }
104
- end
51
+ private
105
52
 
106
- tool.call(args)
107
- rescue => error
108
- { error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
53
+ def tool_for(name)
54
+ tools[@aliases.fetch(name, name)]
109
55
  end
110
56
 
111
- def tool_schema(name)
112
- @tools_by_name[name]&.to_h
57
+ def success(payload)
58
+ payload.merge(ok: true)
113
59
  end
114
60
 
115
- private
116
-
117
- def eval_tool(target_binding)
118
- 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|
119
- { result: target_binding.eval(args["ruby"]) }
120
- end
61
+ def failure(payload)
62
+ payload.merge(ok: false)
121
63
  end
122
64
  end
123
65
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/elelem.rb CHANGED
@@ -1,58 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
4
- require "cli/ui"
3
+ require "date"
5
4
  require "erb"
6
5
  require "fileutils"
7
6
  require "json"
8
- require "json-schema"
9
- require "logger"
10
- require "net/hippie"
11
- require "net/llm"
7
+ require "json_schemer"
12
8
  require "open3"
13
9
  require "pathname"
14
10
  require "reline"
15
- require "set"
16
- require "thor"
17
- require "timeout"
11
+ require "stringio"
12
+ require "tempfile"
18
13
 
19
14
  require_relative "elelem/agent"
20
- require_relative "elelem/application"
21
- require_relative "elelem/conversation"
22
- 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"
23
19
  require_relative "elelem/terminal"
24
20
  require_relative "elelem/tool"
25
21
  require_relative "elelem/toolbox"
26
22
  require_relative "elelem/version"
27
23
 
28
- Reline.input = $stdin
29
- Reline.output = $stdout
30
-
31
24
  module Elelem
32
- 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
33
34
 
34
- class Shell
35
- def execute(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
36
- cmd = command.is_a?(Array) ? command.first : command
37
- cmd_args = command.is_a?(Array) ? command[1..] + args : args
38
- stdout, stderr, status = Open3.capture3(
39
- env,
40
- cmd,
41
- *cmd_args,
42
- chdir: cwd,
43
- stdin_data: stdin
44
- )
45
- {
46
- "exit_status" => status.exitstatus,
47
- "stdout" => stdout.to_s,
48
- "stderr" => stderr.to_s
49
- }
35
+ { exit_status: wait_thr.value.exitstatus, content: output.string }
50
36
  end
51
37
  end
52
38
 
53
- class << self
54
- def shell
55
- @shell ||= Shell.new
56
- 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]
57
49
  end
58
50
  end