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 +4 -4
- data/README.md +5 -5
- data/exe/elelem +0 -6
- data/lib/elelem/agent.rb +18 -55
- data/lib/elelem/builtins/terminal.rb +101 -0
- data/lib/elelem/builtins.rb +52 -0
- data/lib/elelem/cli.rb +23 -68
- data/lib/elelem/commands.rb +8 -13
- data/lib/elelem/conversation.rb +9 -5
- data/lib/elelem/input.rb +10 -17
- data/lib/elelem/null_output.rb +3 -1
- 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 +7 -2
- data/lib/elelem/registration.rb +7 -2
- data/lib/elelem/registry.rb +12 -7
- data/lib/elelem/repl.rb +9 -9
- data/lib/elelem/result.rb +0 -4
- data/lib/elelem/session.rb +5 -1
- data/lib/elelem/stub_provider.rb +67 -6
- data/lib/elelem/system_prompt.rb +1 -1
- data/lib/elelem/tool.rb +7 -20
- data/lib/elelem/toolbox.rb +19 -32
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +29 -23
- metadata +47 -12
- data/lib/elelem/null_tool.rb +0 -18
- data/lib/elelem/schema.rb +0 -20
- data/lib/elelem/slash_command.rb +0 -18
- data/lib/elelem/transcript.rb +0 -26
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
|
@@ -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 =
|
|
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 =
|
|
33
|
+
content, tool_calls = fetch(ctx)
|
|
31
34
|
return content if tool_calls.empty?
|
|
32
35
|
|
|
33
|
-
ctx.
|
|
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
|
|
40
|
+
def fetch(ctx)
|
|
38
41
|
output.waiting
|
|
39
|
-
content, tool_calls
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
output.
|
|
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
|
|
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
|
-
@
|
|
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
|
|
29
|
-
|
|
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("
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
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/conversation.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
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
|
|
26
|
-
return
|
|
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
|
|
32
|
-
|
|
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)
|
data/lib/elelem/null_output.rb
CHANGED
|
@@ -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
|