elelem 0.11.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: 72a4cd636b39b6f30cc0520c3445a3b8748625fd5ca52f15b05d27349a0701b4
4
- data.tar.gz: 12c55aaf15f42e7607fb60ed78500b44655ba6eaab857601ec58073378587866
3
+ metadata.gz: f6da2ace697aa7b578edd8144e10eb779677b24e58d62f1a4a4c88ed042c85b0
4
+ data.tar.gz: ea9e8728d8632514352f34788d200cf8f7f09205e85527ba63b04f5c11fb6c1c
5
5
  SHA512:
6
- metadata.gz: '018856c824b90cbbe658c3b117f683737f14c34f493ba3629281cd7318c11d039585b829e201a9a8bf591619747d2d6c7f1f3568f53a79c40222632a706303ac'
7
- data.tar.gz: ab3132d4613f560dfb97e7bcfa406e5f49ac66f5e642ec85f77c1a25d030825f1a7d9f6401d54dd6eb3f767ec69506b8f114e3a269324fb5fd72f0ca97827a0d
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
@@ -2,94 +2,63 @@
2
2
 
3
3
  module Elelem
4
4
  class Agent
5
- attr_reader :conversation, :toolbox, :input, :output, :commands
5
+ attr_reader :conversation, :toolbox, :output
6
6
  attr_accessor :provider
7
7
 
8
- def initialize(provider, toolbox: Toolbox.new, input: NullInput.new, output: NullOutput.new, system_prompt: nil, commands: 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
- @commands = commands || Commands.new
12
11
  @output = output
13
- @input = input
14
- @conversation = Conversation.new
15
- @system_prompt = SystemPrompt.new(system_prompt)
16
- end
17
-
18
- def repl
19
- output.say "elelem v#{VERSION}"
20
- loop do
21
- line = input.ask("> ")
22
- break if line.nil?
23
- next if line.empty?
24
- line.start_with?("/") ? command(line) : turn(line)
25
- end
12
+ @conversation = Conversation.new(template: template, system_prompt: system_prompt)
26
13
  end
27
14
 
28
15
  def context
29
- @conversation.to_a(system_prompt: @system_prompt.render)
16
+ @conversation.to_a
30
17
  end
31
18
 
19
+ # A provider error leaves the turn: the caller decides how to report it.
32
20
  def turn(prompt)
33
21
  @conversation.add(role: "user", content: prompt)
34
- ctx = []
35
- content = nil
36
-
37
- loop do
38
- output.waiting
39
- content, tool_calls = fetch_response(ctx)
40
- output.say(content, as: :markdown)
41
- break if tool_calls.empty?
42
-
43
- ctx << { role: "assistant", content: content, tool_calls: tool_calls }.compact
44
- tool_calls.each do |tool_call|
45
- ctx << { role: "tool", tool_call_id: tool_call[:id], content: process(tool_call).to_json }
46
- end
47
- end
48
-
22
+ content = run_until_done([])
49
23
  @conversation.add(role: "assistant", content: content)
50
24
  content
51
25
  end
52
26
 
53
27
  private
54
28
 
55
- def command(line)
56
- parts = line.delete_prefix("/").split(" ", 2)
57
- name, args = parts[0], parts[1]
58
- commands.run(name, args) || output.say(commands.names.join(" "))
59
- rescue => e
60
- Elelem.logger.warn("agent: #{e.message}")
61
- output.say(e.message, as: :error)
62
- end
29
+ # Tool calls and their results stay out of the conversation: only the
30
+ # prompt and the final reply are kept.
31
+ def run_until_done(ctx)
32
+ loop do
33
+ content, tool_calls = fetch(ctx)
34
+ return content if tool_calls.empty?
63
35
 
64
- def process(tool_call)
65
- name, args = tool_call[:name], tool_call[:arguments]
66
- output.doing(toolbox.tool_for(name).name, args)
67
- Elelem.logger.debug("agent: #{name}(#{args.inspect})")
68
- result = toolbox.run(name.to_s, args)
69
- Elelem.logger.debug("agent: #{name} -> #{result.inspect}")
70
- result
36
+ ctx.push({ role: "assistant", content: content, tool_calls: tool_calls }, *tool_calls.map { |c| process(c) })
37
+ end
71
38
  end
72
39
 
73
- def fetch_response(ctx)
74
- content = String.new
75
- tool_calls = []
40
+ def fetch(ctx)
41
+ output.waiting
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
46
+ end
76
47
 
77
- provider.fetch(@conversation.to_a(system_prompt: @system_prompt.render) + ctx, toolbox.to_a) do |event|
78
- case event[:type]
79
- when "saying"
80
- content << event[:text].to_s
81
- when "thinking"
82
- output.thinking(event[:text])
83
- when "doing"
84
- tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
85
- end
48
+ def handle_event(event, content, tool_calls)
49
+ case event[:type]
50
+ when "saying" then content << event[:text].to_s
51
+ when "thinking" then output.thinking(event[:text])
52
+ when "doing" then tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
86
53
  end
54
+ end
87
55
 
88
- [content, tool_calls]
89
- rescue => e
90
- Elelem.logger.warn("agent: #{e.message}")
91
- output.say(e.message, as: :error)
92
- ["Error: #{e.message}", []]
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 }
93
62
  end
94
63
  end
95
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,70 +7,65 @@ require "reline"
7
7
 
8
8
  module Elelem
9
9
  class CLI
10
- COMMANDS = %w[chat ask files help].freeze
10
+ COMMANDS = %w[chat ask help].freeze
11
+ USAGE = <<~TEXT.freeze
12
+ Usage: #{File.basename($PROGRAM_NAME)} [command] [options] [args]
13
+ \nCommands:
14
+ chat Interactive REPL (default)
15
+ ask <prompt> One-shot query (reads stdin if piped)
16
+ help Show this help
17
+ \nOptions:
18
+ TEXT
19
+ private_constant :USAGE
11
20
 
12
21
  def initialize(args, provider: "stub")
13
22
  @provider = provider
14
- @args = parse(args)
23
+ @provider_options = {}
24
+ @parser = OptionParser.new(USAGE) { |o| describe_options(o) }
25
+ @args = @parser.parse!(args)
15
26
  end
16
27
 
17
28
  def run
18
29
  command = @args.shift || "chat"
19
- abort "Unknown command: #{command}" unless COMMANDS.include?(command)
20
- 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}"
21
34
  end
22
35
 
23
36
  private
24
37
 
25
- def parse(args)
26
- @parser = OptionParser.new do |o|
27
- o.banner = "Usage: #{File.basename($PROGRAM_NAME)} [command] [options] [args]"
28
- o.separator "\nCommands:"
29
- o.separator " chat Interactive REPL (default)"
30
- o.separator " ask <prompt> One-shot query (reads stdin if piped)"
31
- o.separator " files Output files as XML (no options)"
32
- o.separator " help Show this help"
33
- o.separator "\nOptions:"
34
- o.on("-p", "--provider NAME", "Provider to use (default: #{@provider})") { |p| @provider = p }
35
- o.on("-h", "--help", "Show this help") { puts o; exit }
36
- end
37
- @parser.parse!(args)
38
+ def describe_options(parser)
39
+ parser.on("-p", "--provider NAME", "Provider to use (default: #{@provider})") { |p| @provider = p }
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 }
38
43
  end
39
44
 
40
- def help
41
- puts @parser
42
- end
45
+ def help = puts(@parser)
43
46
 
44
47
  def chat
45
- Elelem.start(provider: @provider)
46
- rescue => e
47
- Elelem.logger.warn("cli: #{e.message}\n#{e.backtrace.join("\n")}")
48
- abort "elelem: #{e.message}"
48
+ Elelem.start(provider: @provider, provider_options: @provider_options, system_prompt: @system_prompt)
49
49
  end
50
50
 
51
+ # When stdout is piped, progress goes to stderr and only the reply to stdout.
51
52
  def ask
52
- abort "Usage: elelem-chat ask <prompt>" if @args.empty?
53
- prompt = @args.join(" ")
54
- prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
53
+ abort "Usage: elelem ask <prompt>" if @args.empty?
54
+
55
55
  piped = !$stdout.tty?
56
- output = Elelem::Output.new(stream: piped ? $stderr : $stdout)
57
- reply = Elelem.ask(prompt, provider: @provider, output: output)
58
- abort "elelem: no reply" if reply.to_s.strip.empty?
59
- Elelem::Output.new.say(reply, as: :markdown) if piped
60
- rescue => e
61
- Elelem.logger.warn("cli: #{e.message}\n#{e.backtrace.join("\n")}")
62
- abort "elelem: #{e.message}"
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)
59
+ end
60
+
61
+ def prompt_for_ask
62
+ prompt = @args.join(" ")
63
+ $stdin.stat.then { |s| s.pipe? || s.file? } ? "#{prompt}\n\n```\n#{$stdin.read}\n```" : prompt
63
64
  end
64
65
 
65
- def files
66
- files = $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
67
- puts "<documents>"
68
- files.each_with_index do |line, i|
69
- path = line.strip
70
- next if path.empty? || !File.file?(path)
71
- puts %Q{<document index="#{i + 1}"><source>#{path}</source><document_content><![CDATA[#{File.read(path)}]]></document_content></document>}
72
- end
73
- puts "</documents>"
74
- end
66
+ def print_reply(reply, piped:)
67
+ abort "elelem: no reply" if reply.to_s.strip.empty?
68
+ $stdout.puts(reply) if piped
69
+ end
75
70
  end
76
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 && 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
data/lib/elelem/config.rb CHANGED
@@ -4,14 +4,14 @@ module Elelem
4
4
  module Config
5
5
  extend SingleForwardable
6
6
 
7
- def_single_delegators :default, :build_provider, :build_agent, :apply, :reload, :names
7
+ def_single_delegators :default, :build_provider, :build_session, :apply, :reload, :names
8
8
 
9
9
  def self.default
10
10
  @default ||= Registry.new
11
11
  end
12
12
  end
13
13
 
14
- def self.configure(&block)
15
- Config.default.configure(&block)
14
+ def self.configure(&)
15
+ Config.default.configure(&)
16
16
  end
17
17
  end
@@ -1,25 +1,30 @@
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)
12
15
  raise ArgumentError, "invalid role: #{role}" unless ROLES.include?(role)
16
+
13
17
  @messages << { role: role, content: content, **extra }.compact
14
18
  end
15
19
 
16
20
  def last = @messages.last
17
- def length = @messages.length
18
21
  def clear! = @messages.clear
19
22
 
20
- def to_a(system_prompt: nil)
21
- base = system_prompt ? [{ role: "system", content: system_prompt }] : []
22
- 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]
23
28
  end
24
29
  end
25
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,27 +26,14 @@ module Elelem
22
26
 
23
27
  def complete(target, preposing)
24
28
  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
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?(" ")
34
31
 
35
32
  complete_files(target)
36
33
  end
37
34
 
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)
35
+ def complete_command_args(preposing, target)
36
+ @commands.completions_for(preposing.delete_prefix("/").split(" ", 2).first, target)
46
37
  end
47
38
 
48
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
data/lib/elelem/output.rb CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Elelem
4
4
  class Output
5
+ RENDERERS = {
6
+ error: ->(text) { "error: #{text}" },
7
+ markdown: ->(text) { "```\n#{text}\n```\n" }
8
+ }.freeze
9
+ private_constant :RENDERERS
10
+
5
11
  def initialize(stream: $stdout, **)
6
12
  @stream = stream
7
13
  end
@@ -26,8 +32,7 @@ module Elelem
26
32
  say "#{state} \e[36m#{name}\e[0m(#{args})"
27
33
  end
28
34
 
29
- def waiting
30
- end
35
+ def waiting; end
31
36
 
32
37
  def display_file(path, fallback: nil)
33
38
  say(fallback || path)
@@ -36,14 +41,7 @@ module Elelem
36
41
  private
37
42
 
38
43
  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
44
+ RENDERERS[as]&.call(text) || text
47
45
  end
48
46
 
49
47
  def write(text)
@@ -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