elelem 0.12.0 → 0.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4696869850fc38fba45a65a38aca74f8663fe9b8562deb7a987cbc71058221a
4
- data.tar.gz: 8ec379df1537243659cfee997c02fde40912be780ace54b14bb0ff181f5fad64
3
+ metadata.gz: f6da2ace697aa7b578edd8144e10eb779677b24e58d62f1a4a4c88ed042c85b0
4
+ data.tar.gz: ea9e8728d8632514352f34788d200cf8f7f09205e85527ba63b04f5c11fb6c1c
5
5
  SHA512:
6
- metadata.gz: 52e225e4b206091bcd1838cce432ee2391c648fa4f995a722dfefc2820985bb405e5e3574a972a8e31be260695eb368f3f6f3dcfa32f8cbeeb76b5fab6b1a2f3
7
- data.tar.gz: c508bfedce1daf081011046ca77a13872af1bb133d161edfb944d7e4019e92222b15dc9aeb4f98376328e099a62e5ec89f2b67d2f595ed6ea6a58281a0a44589
6
+ metadata.gz: 874c019ad05cd113e1c68114648834f2b7a420ea9f3bfe07a378766e117def9a15467d80548d470274c81507524fc1ab5379db92843c1ed5dee66efbb14299bd
7
+ data.tar.gz: 14b4b17556f3fc5830d7579c69b8cbb30048d631135d973aa6368933801e48e3ac3485d3c7b1ba1559b69810d99916a368aa6a860cff3866d1c94df9754ee9e7
data/README.md CHANGED
@@ -23,24 +23,24 @@ gem install elelem
23
23
 
24
24
  ## Usage
25
25
 
26
- Start an interactive chat session:
26
+ Start elelem:
27
27
 
28
28
  ```bash
29
- elelem chat
29
+ elelem
30
30
  ```
31
31
 
32
32
  ## Tools
33
33
 
34
- Use the plugin API to add tools.
34
+ elelem ships with `read`, `write` and `execute`. Use the plugin API to add more.
35
35
 
36
36
  ## Contributing
37
37
 
38
38
  ```bash
39
- $ git clone https://git.mokhan.ca/elelem/elelem.git
39
+ git clone https://github.com/elelem-sh/elelem.git
40
40
  ```
41
41
 
42
42
  Send me a patch via email. For instructions see https://git-send-email.io/.
43
43
 
44
44
  ## License
45
45
 
46
- MIT – see the bundled `LICENSE.txt`.
46
+ MIT. See the bundled `LICENSE.txt`.
data/exe/elelem CHANGED
@@ -3,12 +3,6 @@
3
3
 
4
4
  require "elelem/cli"
5
5
 
6
- begin
7
- require "elelem/builtins"
8
- rescue LoadError
9
- warn "elelem: gem install elelem-builtins for the default tools and commands"
10
- end
11
-
12
6
  Signal.trap("INT") { exit 1 }
13
7
 
14
8
  Elelem::CLI.new(ARGV).run
data/lib/elelem/agent.rb CHANGED
@@ -5,17 +5,18 @@ module Elelem
5
5
  attr_reader :conversation, :toolbox, :output
6
6
  attr_accessor :provider
7
7
 
8
- def initialize(provider, toolbox: Toolbox.new, output: NullOutput.new, system_prompt: nil)
8
+ def initialize(provider, toolbox: Toolbox.new, output: NullOutput.new, template: nil, system_prompt: nil)
9
9
  @provider = provider
10
10
  @toolbox = toolbox
11
11
  @output = output
12
- @conversation = Transcript.new(system_prompt: system_prompt)
12
+ @conversation = Conversation.new(template: template, system_prompt: system_prompt)
13
13
  end
14
14
 
15
15
  def context
16
16
  @conversation.to_a
17
17
  end
18
18
 
19
+ # A provider error leaves the turn: the caller decides how to report it.
19
20
  def turn(prompt)
20
21
  @conversation.add(role: "user", content: prompt)
21
22
  content = run_until_done([])
@@ -25,64 +26,23 @@ module Elelem
25
26
 
26
27
  private
27
28
 
29
+ # Tool calls and their results stay out of the conversation: only the
30
+ # prompt and the final reply are kept.
28
31
  def run_until_done(ctx)
29
32
  loop do
30
- content, tool_calls = fetch_and_say(ctx)
33
+ content, tool_calls = fetch(ctx)
31
34
  return content if tool_calls.empty?
32
35
 
33
- ctx.concat(tool_turn(content, tool_calls))
36
+ ctx.push({ role: "assistant", content: content, tool_calls: tool_calls }, *tool_calls.map { |c| process(c) })
34
37
  end
35
38
  end
36
39
 
37
- def fetch_and_say(ctx)
40
+ def fetch(ctx)
38
41
  output.waiting
39
- content, tool_calls = fetch_response(ctx)
40
- output.say(content, as: :markdown)
41
- [content, tool_calls]
42
- end
43
-
44
- def tool_turn(content, tool_calls)
45
- results = tool_calls.map { |tool_call| tool_result(tool_call) }
46
- [{ role: "assistant", content: content, tool_calls: tool_calls }.compact, *results]
47
- end
48
-
49
- def tool_result(tool_call)
50
- { role: "tool", tool_call_id: tool_call[:id], content: process(tool_call).to_json }
51
- end
52
-
53
- def process(tool_call)
54
- name = tool_call[:name]
55
- args = tool_call[:arguments]
56
- output.doing(toolbox.tool_for(name).name, args)
57
- Elelem.logger.debug("agent: #{name}(#{args.inspect})")
58
- log_result(name, toolbox.run(name.to_s, args))
59
- end
60
-
61
- def log_result(name, result)
62
- Elelem.logger.debug("agent: #{name} -> #{result.inspect}")
63
- result
64
- end
65
-
66
- def fetch_response(ctx)
67
- collect_response(ctx)
68
- rescue StandardError, ScriptError => e
69
- fetch_error(e)
70
- end
71
-
72
- def collect_response(ctx)
73
- content = String.new
74
- tool_calls = []
75
- provider.fetch(messages(ctx), toolbox.to_a) { |event| handle_event(event, content, tool_calls) }
76
- [content, tool_calls]
77
- end
78
-
79
- def messages(ctx)
80
- @conversation.to_a(ctx)
81
- end
82
-
83
- def fetch_error(error)
84
- warn_and_say(error)
85
- ["Error: #{error.message}", []]
42
+ [String.new, []].tap do |content, tool_calls|
43
+ provider.fetch(@conversation.to_a(ctx), toolbox.to_a) { |event| handle_event(event, content, tool_calls) }
44
+ output.say(content, as: :markdown)
45
+ end
86
46
  end
87
47
 
88
48
  def handle_event(event, content, tool_calls)
@@ -93,9 +53,12 @@ module Elelem
93
53
  end
94
54
  end
95
55
 
96
- def warn_and_say(error)
97
- Elelem.logger.warn("agent: #{error.message}")
98
- output.say(error.message, as: :error)
56
+ def process(call)
57
+ name, args = call.values_at(:name, :arguments)
58
+ output.doing(toolbox.tool_for(name)&.name || "#{name}?", args)
59
+ result = toolbox.run(name.to_s, args).to_json
60
+ Elelem.logger.debug("agent: #{name}(#{args.inspect}) -> #{result}")
61
+ { role: "tool", tool_call_id: call[:id], content: result }
99
62
  end
100
63
  end
101
64
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Builtins
5
+ # Output for a person at a terminal: dots while the model thinks, markdown
6
+ # through glow, files through bat, and a copy of everything in a private
7
+ # transcript under $XDG_STATE_HOME/elelem/sessions.
8
+ class Terminal < Output
9
+ def initialize(stream: $stdout, **)
10
+ super
11
+ @dots_thread = nil
12
+ @at_line_start = true
13
+ end
14
+
15
+ def say(text, as: nil)
16
+ stop_dots
17
+ super
18
+ end
19
+
20
+ def print(text)
21
+ stop_dots
22
+ super
23
+ end
24
+
25
+ def doing(name, args, state: "+")
26
+ gap
27
+ super
28
+ end
29
+
30
+ def waiting
31
+ stop_dots
32
+ @dots_thread = Thread.new do
33
+ until Thread.current[:stop]
34
+ @stream.print "."
35
+ @stream.flush
36
+ sleep 0.1
37
+ end
38
+ end
39
+ end
40
+
41
+ def display_file(path, fallback: nil)
42
+ system("bat", "--paging=never", "--", path) || super
43
+ end
44
+
45
+ private
46
+
47
+ def render(text, as)
48
+ case as
49
+ when :error then "\n x #{text}"
50
+ when :markdown then markdown(text)
51
+ else text
52
+ end
53
+ end
54
+
55
+ def markdown(text)
56
+ gap
57
+ width = begin
58
+ @stream.winsize[1]
59
+ rescue StandardError
60
+ 80
61
+ end
62
+ IO.popen(["glow", "-s", "dark", "-w", width.to_s, "-"], "r+") do |io|
63
+ io.write(text)
64
+ io.close_write
65
+ io.read
66
+ end
67
+ rescue Errno::ENOENT
68
+ text
69
+ end
70
+
71
+ def gap
72
+ stop_dots
73
+ write("\n") unless @at_line_start
74
+ end
75
+
76
+ def stop_dots
77
+ return unless @dots_thread
78
+
79
+ @dots_thread[:stop] = true
80
+ @dots_thread.join
81
+ @dots_thread = nil
82
+ write("\n")
83
+ end
84
+
85
+ def write(text)
86
+ @stream.print text
87
+ @at_line_start = text.end_with?("\n")
88
+ (@transcript ||= open_transcript).print text
89
+ end
90
+
91
+ def open_transcript
92
+ state_dir = File.join(ENV.fetch("XDG_STATE_HOME", File.expand_path("~/.local/state")), "elelem", "sessions")
93
+ FileUtils.mkdir_p(state_dir, mode: 0o700)
94
+ File.chmod(0o700, state_dir)
95
+ session_id = "#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{Process.pid}"
96
+ path = File.join(state_dir, "#{session_id}.transcript")
97
+ File.open(path, "a", 0o600).tap { |f| f.sync = true } # rubocop:disable Style/FileOpen
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Builtins
5
+ def self.sh(cmd, args: [], cwd: Dir.pwd, env: {}, timeout: nil)
6
+ output = StringIO.new
7
+ options = { chdir: cwd }
8
+ options[:pgroup] = true if timeout
9
+
10
+ Open3.popen2e(env, cmd, *args, **options) do |stdin, out, wait_thr|
11
+ stdin.close
12
+ timed_out = false
13
+ watchdog = timeout && Thread.new do
14
+ sleep(timeout)
15
+ timed_out = true
16
+ terminate(wait_thr.pid)
17
+ end
18
+
19
+ out.each_line do |line|
20
+ line = line.force_encoding(Encoding::UTF_8).scrub
21
+ yield line if block_given?
22
+ output.write(line)
23
+ end
24
+ timed_out ? watchdog&.join : watchdog&.kill
25
+
26
+ status = wait_thr.value
27
+ note = timed_out ? "\n[command timed out after #{timeout}s]" : ""
28
+ { exit_status: status.exitstatus || (timed_out ? 124 : 1), content: truncate(output.string) + note }
29
+ rescue Interrupt
30
+ # In its own process group the command never sees Ctrl-C, so stop it here.
31
+ terminate(wait_thr.pid) if timeout
32
+ raise
33
+ end
34
+ end
35
+
36
+ def self.truncate(content, limit: 10_000)
37
+ return content if content.length <= limit
38
+
39
+ half = limit / 2
40
+ elided = content.length - limit
41
+ "#{content[0, half]}\n[#{elided} characters elided]\n#{content[-half, half]}"
42
+ end
43
+
44
+ def self.terminate(pid)
45
+ Process.kill("TERM", -pid)
46
+ sleep 0.5
47
+ Process.kill("KILL", -pid)
48
+ rescue Errno::ESRCH
49
+ nil
50
+ end
51
+ end
52
+ end
data/lib/elelem/cli.rb CHANGED
@@ -7,12 +7,12 @@ require "reline"
7
7
 
8
8
  module Elelem
9
9
  class CLI
10
- COMMANDS = %w[chat ask files help].freeze
11
- USAGE = <<~TEXT
10
+ COMMANDS = %w[chat ask help].freeze
11
+ USAGE = <<~TEXT.freeze
12
+ Usage: #{File.basename($PROGRAM_NAME)} [command] [options] [args]
12
13
  \nCommands:
13
14
  chat Interactive REPL (default)
14
15
  ask <prompt> One-shot query (reads stdin if piped)
15
- files Output files as XML (no options)
16
16
  help Show this help
17
17
  \nOptions:
18
18
  TEXT
@@ -20,97 +20,52 @@ module Elelem
20
20
 
21
21
  def initialize(args, provider: "stub")
22
22
  @provider = provider
23
- @args = parse(args)
23
+ @provider_options = {}
24
+ @parser = OptionParser.new(USAGE) { |o| describe_options(o) }
25
+ @args = @parser.parse!(args)
24
26
  end
25
27
 
26
28
  def run
27
29
  command = @args.shift || "chat"
28
- abort "Unknown command: #{command}" unless COMMANDS.include?(command)
29
- send(command)
30
+ COMMANDS.include?(command) ? send(command) : abort("Unknown command: #{command}")
31
+ rescue StandardError, ScriptError => e
32
+ Elelem.logger.debug("cli: #{e.message}\n#{e.backtrace.join("\n")}")
33
+ abort "elelem: #{e.message}"
30
34
  end
31
35
 
32
36
  private
33
37
 
34
- def parse(args)
35
- @parser = OptionParser.new { |o| describe_options(o) }
36
- @parser.parse!(args)
37
- end
38
-
39
38
  def describe_options(parser)
40
- describe_usage(parser)
41
39
  parser.on("-p", "--provider NAME", "Provider to use (default: #{@provider})") { |p| @provider = p }
42
- parser.on("-h", "--help", "Show this help") { print_help_and_exit(parser) }
43
- end
44
-
45
- def print_help_and_exit(parser)
46
- puts parser
47
- exit
48
- end
49
-
50
- def describe_usage(parser)
51
- parser.banner = "Usage: #{File.basename($PROGRAM_NAME)} [command] [options] [args]"
52
- parser.separator USAGE
40
+ parser.on("--model NAME", "Model to use, passed to the provider") { |m| @provider_options[:model] = m }
41
+ parser.on("--system-prompt TEXT", "Role text prepended to the system prompt") { |s| @system_prompt = s }
42
+ parser.on("-h", "--help", "Show this help") { puts(parser) || exit }
53
43
  end
54
44
 
55
- def help
56
- puts @parser
57
- end
45
+ def help = puts(@parser)
58
46
 
59
47
  def chat
60
- Elelem.start(provider: @provider)
61
- rescue StandardError, ScriptError => e
62
- warn_and_abort(e)
48
+ Elelem.start(provider: @provider, provider_options: @provider_options, system_prompt: @system_prompt)
63
49
  end
64
50
 
51
+ # When stdout is piped, progress goes to stderr and only the reply to stdout.
65
52
  def ask
66
- abort "Usage: elelem-chat ask <prompt>" if @args.empty?
67
-
68
- reply = Elelem.ask(prompt_for_ask, provider: @provider, output: output_for_ask)
69
- print_reply(reply, piped: piped?)
70
- rescue StandardError, ScriptError => e
71
- warn_and_abort(e)
72
- end
53
+ abort "Usage: elelem ask <prompt>" if @args.empty?
73
54
 
74
- def piped?
75
- !$stdout.tty?
76
- end
77
-
78
- def output_for_ask
79
- Elelem::Output.new(stream: piped? ? $stderr : $stdout)
55
+ piped = !$stdout.tty?
56
+ reply = Elelem.ask(prompt_for_ask, provider: @provider, output: Output.new(stream: piped ? $stderr : $stdout),
57
+ provider_options: @provider_options, system_prompt: @system_prompt)
58
+ print_reply(reply, piped: piped)
80
59
  end
81
60
 
82
61
  def prompt_for_ask
83
62
  prompt = @args.join(" ")
84
- $stdin.stat.pipe? ? "#{prompt}\n\n```\n#{$stdin.read}\n```" : prompt
63
+ $stdin.stat.then { |s| s.pipe? || s.file? } ? "#{prompt}\n\n```\n#{$stdin.read}\n```" : prompt
85
64
  end
86
65
 
87
66
  def print_reply(reply, piped:)
88
67
  abort "elelem: no reply" if reply.to_s.strip.empty?
89
- Elelem::Output.new.say(reply, as: :markdown) if piped
90
- end
91
-
92
- def warn_and_abort(error)
93
- Elelem.logger.warn("cli: #{error.message}\n#{error.backtrace.join("\n")}")
94
- abort "elelem: #{error.message}"
95
- end
96
-
97
- def files
98
- puts "<documents>"
99
- candidate_files.each_with_index { |line, i| print_document(line, i) }
100
- puts "</documents>"
101
- end
102
-
103
- def candidate_files
104
- $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
105
- end
106
-
107
- def print_document(line, index)
108
- path = line.strip
109
- return if path.empty? || !File.file?(path)
110
-
111
- content = File.read(path)
112
- puts %(<document index="#{index + 1}"><source>#{path}</source>) +
113
- %(<document_content><![CDATA[#{content}]]></document_content></document>)
68
+ $stdout.puts(reply) if piped
114
69
  end
115
70
  end
116
71
  end
@@ -4,6 +4,8 @@ module Elelem
4
4
  class Commands
5
5
  include Enumerable
6
6
 
7
+ Command = Struct.new(:description, :completions, :handler)
8
+
7
9
  def initialize(registry = {})
8
10
  @registry = registry
9
11
  end
@@ -13,22 +15,21 @@ module Elelem
13
15
  end
14
16
 
15
17
  def register(name, description: "", completions: nil, &handler)
16
- @registry[name] = SlashCommand.new(name, description, completions, &handler)
18
+ @registry[name] = Command.new(description, completions, handler)
17
19
  end
18
20
 
21
+ # completions is a list, or a lambda that returns one.
19
22
  def completions_for(name, partial = "")
20
- cmd = command_for(name)
21
- return [] unless cmd&.completions
22
-
23
- options = cmd.completions.respond_to?(:call) ? cmd.completions.call : cmd.completions
23
+ options = @registry[name]&.completions || []
24
+ options = options.call if options.respond_to?(:call)
24
25
  options.select { |o| o.start_with?(partial) }
25
26
  end
26
27
 
27
28
  def run(name, args = nil)
28
- command = command_for(name)
29
+ command = @registry[name]
29
30
  return false unless command
30
31
 
31
- command.call(args)
32
+ command.handler.call(args)
32
33
  true
33
34
  end
34
35
 
@@ -39,11 +40,5 @@ module Elelem
39
40
  def each
40
41
  @registry.each { |name, command| yield "/#{name}", command.description }
41
42
  end
42
-
43
- private
44
-
45
- def command_for(name)
46
- @registry[name]
47
- end
48
43
  end
49
44
  end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
+ # The messages of a session, behind the system prompt they are sent with.
4
5
  class Conversation
5
6
  ROLES = %w[user assistant tool].freeze
6
7
 
7
- def initialize
8
+ def initialize(template: nil, system_prompt: nil)
8
9
  @messages = []
10
+ @template = SystemPrompt.new(template)
11
+ @system_prompt = system_prompt
9
12
  end
10
13
 
11
14
  def add(role:, content:, **extra)
@@ -15,12 +18,13 @@ module Elelem
15
18
  end
16
19
 
17
20
  def last = @messages.last
18
- def length = @messages.length
19
21
  def clear! = @messages.clear
20
22
 
21
- def to_a(system_prompt: nil)
22
- base = system_prompt ? [{ role: "system", content: system_prompt }] : []
23
- base + @messages
23
+ # The system prompt, the messages, then extra: the tool calls of a turn.
24
+ def to_a(extra = [])
25
+ # The role text is the model's own words, so it is never run as ERB.
26
+ system = [@system_prompt, @template.render].compact.join("\n\n")
27
+ [{ role: "system", content: system }, *@messages, *extra]
24
28
  end
25
29
  end
26
30
  end
data/lib/elelem/input.rb CHANGED
@@ -4,14 +4,18 @@ require "reline"
4
4
 
5
5
  module Elelem
6
6
  class Input
7
- def initialize(commands: [])
7
+ def initialize(commands: Commands.new)
8
8
  @commands = commands
9
9
  Reline.autocompletion = true
10
10
  Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
11
11
  end
12
12
 
13
13
  def ask(prompt)
14
- Reline.readline(prompt, true)&.strip
14
+ Reline.readline(prompt, false)&.strip
15
+ end
16
+
17
+ def remember(line)
18
+ Reline::HISTORY << line
15
19
  end
16
20
 
17
21
  def interactive?
@@ -22,25 +26,14 @@ module Elelem
22
26
 
23
27
  def complete(target, preposing)
24
28
  line = "#{preposing}#{target}"
25
- return command_names.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
26
- return complete_command_args_for(preposing, target) if preposing.start_with?("/") && preposing.include?(" ")
29
+ return @commands.names.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
30
+ return complete_command_args(preposing, target) if preposing.start_with?("/") && preposing.include?(" ")
27
31
 
28
32
  complete_files(target)
29
33
  end
30
34
 
31
- def complete_command_args_for(preposing, target)
32
- cmd_name = preposing.delete_prefix("/").split(" ", 2).first
33
- complete_command_args(cmd_name, target)
34
- end
35
-
36
- def command_names
37
- @commands.respond_to?(:names) ? @commands.names : @commands
38
- end
39
-
40
- def complete_command_args(cmd_name, partial)
41
- return [] unless @commands.respond_to?(:completions_for)
42
-
43
- @commands.completions_for(cmd_name, partial)
35
+ def complete_command_args(preposing, target)
36
+ @commands.completions_for(preposing.delete_prefix("/").split(" ", 2).first, target)
44
37
  end
45
38
 
46
39
  def complete_files(target)
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Elelem
4
4
  class NullOutput < Output
5
- def say(*, **) = nil
5
+ private
6
+
7
+ def write(_text) = nil
6
8
  end
7
9
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem.configure do |config|
4
+ config.setup(:builtins) do |agent|
5
+ agent.commands.register("exit", description: "Exit elelem") { exit(0) }
6
+
7
+ agent.commands.register("clear", description: "Clear conversation history") do
8
+ agent.conversation.clear!
9
+ agent.output.say " -> context cleared"
10
+ end
11
+
12
+ agent.commands.register("help", description: "Show available commands") do
13
+ agent.output.say agent.commands.map { |name, description| "#{name.ljust(12)} #{description}" }.join("\n")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem.configure do |config|
4
+ config.setup(:context) do |agent|
5
+ agent.commands.register("context", description: "Show conversation context") do |args|
6
+ messages = agent.context
7
+
8
+ case args
9
+ when nil, ""
10
+ messages.each_with_index do |msg, i|
11
+ role = msg[:role]
12
+ preview = msg[:content].to_s.lines.first&.strip&.slice(0, 60) || ""
13
+ preview += "..." if msg[:content].to_s.length > 60
14
+ agent.output.say " #{i + 1}. #{role}: #{preview}"
15
+ end
16
+ when "json"
17
+ agent.output.say JSON.pretty_generate(messages)
18
+ when /^\d+$/
19
+ index = args.to_i - 1
20
+ if index >= 0 && index < messages.length
21
+ content = messages[index][:content].to_s
22
+ agent.output.say(content, as: :markdown)
23
+ else
24
+ agent.output.say " Invalid index: #{args}"
25
+ end
26
+ else
27
+ agent.output.say " Usage: /context [json|<number>]"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ Elelem.configure do |config|
4
+ config.setup(:execute) do |agent|
5
+ agent.toolbox.add("execute",
6
+ description: "Run shell command (supports pipes and redirections)",
7
+ params: { command: { type: "string" } },
8
+ required: ["command"],
9
+ aliases: ["bash", "sh", "exec", "execute<|channel|>"]) do |a|
10
+ # The timeout kills the whole process group, so `npm start &` cannot hang the session.
11
+ value = ENV.fetch("ELELEM_CMD_TIMEOUT", "")
12
+ timeout = value.empty? ? 300 : Integer(value)
13
+ timeout = nil if timeout.zero?
14
+ result = Elelem::Builtins.sh("bash", args: ["-c", a["command"]], timeout: timeout) { |x| agent.output.print(x) }
15
+ result.merge(ok: result[:exit_status].zero?)
16
+ end
17
+
18
+ agent.toolbox.after("execute") do |args, result|
19
+ next if result[:exit_status].zero?
20
+
21
+ agent.output.doing("execute", args, state: "x")
22
+ end
23
+ end
24
+ end