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 +4 -4
- data/README.md +5 -5
- data/exe/elelem +0 -6
- data/lib/elelem/agent.rb +33 -64
- data/lib/elelem/builtins/terminal.rb +101 -0
- data/lib/elelem/builtins.rb +52 -0
- data/lib/elelem/cli.rb +39 -44
- data/lib/elelem/commands.rb +8 -13
- data/lib/elelem/config.rb +3 -3
- data/lib/elelem/conversation.rb +10 -5
- data/lib/elelem/input.rb +10 -19
- data/lib/elelem/null_output.rb +3 -1
- data/lib/elelem/output.rb +8 -10
- data/lib/elelem/plugins/builtins.rb +16 -0
- data/lib/elelem/plugins/context.rb +31 -0
- data/lib/elelem/plugins/execute.rb +24 -0
- data/lib/elelem/plugins/provider.rb +22 -0
- data/lib/elelem/plugins/read.rb +29 -0
- data/lib/elelem/plugins/reload.rb +9 -0
- data/lib/elelem/plugins/terminal.rb +6 -0
- data/lib/elelem/plugins/tools.rb +31 -0
- data/lib/elelem/plugins/write.rb +37 -0
- data/lib/elelem/plugins.rb +19 -10
- data/lib/elelem/registration.rb +7 -2
- data/lib/elelem/registry.rb +29 -25
- data/lib/elelem/repl.rb +34 -0
- data/lib/elelem/result.rb +1 -4
- data/lib/elelem/session.rb +21 -0
- data/lib/elelem/stub_provider.rb +67 -6
- data/lib/elelem/system_prompt.rb +10 -1
- data/lib/elelem/tool.rb +9 -28
- data/lib/elelem/toolbox.rb +28 -23
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +29 -19
- metadata +50 -10
- data/lib/elelem/null_tool.rb +0 -18
- data/lib/elelem/slash_command.rb +0 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6da2ace697aa7b578edd8144e10eb779677b24e58d62f1a4a4c88ed042c85b0
|
|
4
|
+
data.tar.gz: ea9e8728d8632514352f34788d200cf8f7f09205e85527ba63b04f5c11fb6c1c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
26
|
+
Start elelem:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
elelem
|
|
29
|
+
elelem
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Tools
|
|
33
33
|
|
|
34
|
-
Use the plugin API to add
|
|
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
|
-
|
|
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
|
|
46
|
+
MIT. See the bundled `LICENSE.txt`.
|
data/exe/elelem
CHANGED
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, :
|
|
5
|
+
attr_reader :conversation, :toolbox, :output
|
|
6
6
|
attr_accessor :provider
|
|
7
7
|
|
|
8
|
-
def initialize(provider, toolbox: Toolbox.new,
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
@
|
|
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
|
|
20
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
puts
|
|
68
|
-
|
|
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
|
data/lib/elelem/commands.rb
CHANGED
|
@@ -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] =
|
|
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
|
-
|
|
21
|
-
|
|
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 =
|
|
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, :
|
|
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(&
|
|
15
|
-
Config.default.configure(&
|
|
14
|
+
def self.configure(&)
|
|
15
|
+
Config.default.configure(&)
|
|
16
16
|
end
|
|
17
17
|
end
|
data/lib/elelem/conversation.rb
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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,
|
|
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
|
|
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
|
|
39
|
-
@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)
|
data/lib/elelem/null_output.rb
CHANGED
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
|
-
|
|
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
|