elelem 0.6.0 → 0.7.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/CHANGELOG.md +17 -0
- data/lib/elelem/agent.rb +30 -36
- data/lib/elelem/conversation.rb +6 -6
- data/lib/elelem/git_context.rb +79 -0
- data/lib/elelem/system_prompt.erb +4 -0
- data/lib/elelem/terminal.rb +24 -0
- data/lib/elelem/toolbox.rb +16 -6
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f36b6e8749d5c12525548bc5cfd8b270c2bd5f7ada78855f7b4c55e23e3cb52
|
|
4
|
+
data.tar.gz: 8bf6ae1b937a7fbf5dc22e0b0b522e903803a6562644afa04cf2b00c8d28133b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1eccfe3793dc81e84c8c5a82b287732dccab7be793c350ad65e20466d3c29ea183ec1aca561969e2b8a78c5095668329c672ea650f5a97bb5aa52f1d8680379e
|
|
7
|
+
data.tar.gz: c24188e1de12fb63452d4a34cfa48de11795bc18b85e3b95b7f620959c9e2cf084b6f9dba8b492bdbea5f5cd5f27c07d6fa9657e2ea0b37564ddf1736baf1dba
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-01-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- ASCII spinner animation while waiting for LLM responses
|
|
7
|
+
- `Terminal#waiting` method with automatic cleanup on next output
|
|
8
|
+
- Decision-making principles in system prompt (prefer reversible actions, ask when uncertain)
|
|
9
|
+
- Mode enforcement tests
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Renamed internal `mode` concept to `permissions` for clarity (read/write/execute are permissions, plan/build/verify are modes)
|
|
13
|
+
- Refactored `Toolbox#run_tool` to accept `permissions:` parameter
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Security**: Mode restrictions now enforced at execution time, not just schema time
|
|
17
|
+
- Previously, LLMs could call tools outside their mode by guessing tool names
|
|
18
|
+
- Now `run_tool` validates the tool is allowed for the current permission set
|
|
19
|
+
|
|
3
20
|
## [0.6.0] - 2026-01-12
|
|
4
21
|
|
|
5
22
|
### Added
|
data/lib/elelem/agent.rb
CHANGED
|
@@ -9,32 +9,26 @@ module Elelem
|
|
|
9
9
|
MODES = %w[auto build plan verify].freeze
|
|
10
10
|
ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
|
|
11
11
|
|
|
12
|
-
attr_reader :conversation, :client, :toolbox, :provider, :terminal
|
|
12
|
+
attr_reader :conversation, :client, :toolbox, :provider, :terminal, :permissions
|
|
13
13
|
|
|
14
14
|
def initialize(provider, model, toolbox, terminal: nil)
|
|
15
15
|
@conversation = Conversation.new
|
|
16
16
|
@provider = provider
|
|
17
17
|
@toolbox = toolbox
|
|
18
18
|
@client = build_client(provider, model)
|
|
19
|
-
@terminal = terminal ||
|
|
20
|
-
|
|
21
|
-
modes: MODES,
|
|
22
|
-
providers: PROVIDERS,
|
|
23
|
-
env_vars: ENV_VARS
|
|
24
|
-
)
|
|
19
|
+
@terminal = terminal || default_terminal
|
|
20
|
+
@permissions = Set.new([:read])
|
|
25
21
|
end
|
|
26
22
|
|
|
27
23
|
def repl
|
|
28
|
-
mode = Set.new([:read])
|
|
29
|
-
|
|
30
24
|
loop do
|
|
31
25
|
input = terminal.ask("User> ")
|
|
32
26
|
break if input.nil?
|
|
33
27
|
if input.start_with?("/")
|
|
34
|
-
|
|
28
|
+
handle_slash_command(input)
|
|
35
29
|
else
|
|
36
30
|
conversation.add(role: :user, content: input)
|
|
37
|
-
result = execute_turn(conversation.history_for(
|
|
31
|
+
result = execute_turn(conversation.history_for(permissions))
|
|
38
32
|
conversation.add(role: result[:role], content: result[:content])
|
|
39
33
|
end
|
|
40
34
|
end
|
|
@@ -42,55 +36,54 @@ module Elelem
|
|
|
42
36
|
|
|
43
37
|
private
|
|
44
38
|
|
|
45
|
-
def
|
|
39
|
+
def default_terminal
|
|
40
|
+
Terminal.new(
|
|
41
|
+
commands: COMMANDS,
|
|
42
|
+
env_vars: ENV_VARS,
|
|
43
|
+
modes: MODES,
|
|
44
|
+
providers: PROVIDERS
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle_slash_command(input)
|
|
46
49
|
case input
|
|
47
50
|
when "/mode auto"
|
|
48
|
-
|
|
51
|
+
permissions.replace([:read, :write, :execute])
|
|
49
52
|
terminal.say " → Mode: auto (all tools enabled)"
|
|
50
53
|
when "/mode build"
|
|
51
|
-
|
|
54
|
+
permissions.replace([:read, :write])
|
|
52
55
|
terminal.say " → Mode: build (read + write)"
|
|
53
56
|
when "/mode plan"
|
|
54
|
-
|
|
57
|
+
permissions.replace([:read])
|
|
55
58
|
terminal.say " → Mode: plan (read-only)"
|
|
56
59
|
when "/mode verify"
|
|
57
|
-
|
|
60
|
+
permissions.replace([:read, :execute])
|
|
58
61
|
terminal.say " → Mode: verify (read + execute)"
|
|
59
62
|
when "/mode"
|
|
60
63
|
terminal.say " Usage: /mode [auto|build|plan|verify]"
|
|
61
64
|
terminal.say ""
|
|
62
65
|
terminal.say " Provider: #{provider}/#{client.model}"
|
|
63
|
-
terminal.say "
|
|
64
|
-
terminal.say " Tools: #{toolbox.tools_for(
|
|
66
|
+
terminal.say " Permissions: #{permissions.to_a.inspect}"
|
|
67
|
+
terminal.say " Tools: #{toolbox.tools_for(permissions).map { |t| t.dig(:function, :name) }}"
|
|
65
68
|
when "/exit" then exit
|
|
66
69
|
when "/clear"
|
|
67
70
|
conversation.clear
|
|
68
71
|
terminal.say " → Conversation cleared"
|
|
69
72
|
when "/context"
|
|
70
|
-
terminal.say conversation.dump(
|
|
73
|
+
terminal.say conversation.dump(permissions)
|
|
71
74
|
when "/shell"
|
|
72
75
|
transcript = start_shell
|
|
73
76
|
conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
|
|
74
77
|
terminal.say " → Shell session captured"
|
|
75
78
|
when "/provider"
|
|
76
79
|
terminal.select("Provider?", PROVIDERS) do |selected_provider|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
terminal.say " ✗ No models available for #{selected_provider}"
|
|
80
|
-
else
|
|
81
|
-
terminal.select("Model?", models) do |m|
|
|
82
|
-
switch_client(selected_provider, m)
|
|
83
|
-
end
|
|
80
|
+
terminal.select("Model?", models_for(selected_provider)) do |m|
|
|
81
|
+
switch_client(selected_provider, m)
|
|
84
82
|
end
|
|
85
83
|
end
|
|
86
84
|
when "/model"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
terminal.say " ✗ No models available for #{provider}"
|
|
90
|
-
else
|
|
91
|
-
terminal.select("Model?", models) do |m|
|
|
92
|
-
switch_model(m)
|
|
93
|
-
end
|
|
85
|
+
terminal.select("Model?", models_for(provider)) do |m|
|
|
86
|
+
switch_model(m)
|
|
94
87
|
end
|
|
95
88
|
when "/env"
|
|
96
89
|
terminal.say " Usage: /env VAR cmd..."
|
|
@@ -236,7 +229,8 @@ module Elelem
|
|
|
236
229
|
client.is_a?(Net::Llm::OpenAI)
|
|
237
230
|
end
|
|
238
231
|
|
|
239
|
-
def execute_turn(messages
|
|
232
|
+
def execute_turn(messages)
|
|
233
|
+
tools = toolbox.tools_for(permissions)
|
|
240
234
|
turn_context = []
|
|
241
235
|
errors = 0
|
|
242
236
|
|
|
@@ -244,7 +238,7 @@ module Elelem
|
|
|
244
238
|
content = ""
|
|
245
239
|
tool_calls = []
|
|
246
240
|
|
|
247
|
-
terminal.
|
|
241
|
+
terminal.waiting
|
|
248
242
|
begin
|
|
249
243
|
client.fetch(messages + turn_context, tools) do |chunk|
|
|
250
244
|
case chunk[:type]
|
|
@@ -269,7 +263,7 @@ module Elelem
|
|
|
269
263
|
tool_calls.each do |call|
|
|
270
264
|
name, args = call[:name], call[:arguments]
|
|
271
265
|
terminal.say "\nTool> #{name}(#{args})"
|
|
272
|
-
result = toolbox.run_tool(name, args)
|
|
266
|
+
result = toolbox.run_tool(name, args, permissions: permissions)
|
|
273
267
|
terminal.say truncate_output(format_tool_call_result(result))
|
|
274
268
|
turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
|
|
275
269
|
errors += 1 if result[:error]
|
data/lib/elelem/conversation.rb
CHANGED
|
@@ -8,9 +8,9 @@ module Elelem
|
|
|
8
8
|
@items = items
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def history_for(
|
|
11
|
+
def history_for(permissions)
|
|
12
12
|
history = @items.dup
|
|
13
|
-
history[0] = { role: "system", content: system_prompt_for(
|
|
13
|
+
history[0] = { role: "system", content: system_prompt_for(permissions) }
|
|
14
14
|
history
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -30,8 +30,8 @@ module Elelem
|
|
|
30
30
|
@items = default_context
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def dump(
|
|
34
|
-
JSON.pretty_generate(history_for(
|
|
33
|
+
def dump(permissions)
|
|
34
|
+
JSON.pretty_generate(history_for(permissions))
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
private
|
|
@@ -40,10 +40,10 @@ module Elelem
|
|
|
40
40
|
[{ role: "system", content: prompt }]
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def system_prompt_for(
|
|
43
|
+
def system_prompt_for(permissions)
|
|
44
44
|
base = system_prompt
|
|
45
45
|
|
|
46
|
-
case
|
|
46
|
+
case permissions.sort
|
|
47
47
|
when [:read]
|
|
48
48
|
"#{base}\n\nYou may read files on the system."
|
|
49
49
|
when [:write]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class GitContext
|
|
5
|
+
MAX_DIFF_LINES = 100
|
|
6
|
+
|
|
7
|
+
def initialize(shell = Elelem.shell)
|
|
8
|
+
@shell = shell
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
return "" unless git_repo?
|
|
13
|
+
|
|
14
|
+
parts = []
|
|
15
|
+
parts << "Branch: #{branch}" if branch
|
|
16
|
+
parts << status_section if status.any?
|
|
17
|
+
parts << diff_section if staged_diff.any? || unstaged_diff.any?
|
|
18
|
+
parts << recent_commits_section if recent_commits.any?
|
|
19
|
+
parts.join("\n\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def git_repo?
|
|
25
|
+
@shell.execute("git", args: ["rev-parse", "--git-dir"])["exit_status"].zero?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def branch
|
|
29
|
+
@branch ||= @shell.execute("git", args: ["branch", "--show-current"])["stdout"].strip.then { |b| b.empty? ? nil : b }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def status
|
|
33
|
+
@status ||= @shell.execute("git", args: ["status", "--porcelain"])["stdout"].lines.map(&:chomp)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def staged_diff
|
|
37
|
+
@staged_diff ||= @shell.execute("git", args: ["diff", "--cached", "--stat"])["stdout"].lines
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def unstaged_diff
|
|
41
|
+
@unstaged_diff ||= @shell.execute("git", args: ["diff", "--stat"])["stdout"].lines
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def recent_commits
|
|
45
|
+
@recent_commits ||= @shell.execute("git", args: ["log", "--oneline", "-5"])["stdout"].lines.map(&:strip)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def status_section
|
|
49
|
+
modified = status.select { |l| l[0] == "M" || l[1] == "M" }.map { |l| l[3..] }
|
|
50
|
+
added = status.select { |l| l[0] == "A" || l.start_with?("??") }.map { |l| l[3..] }
|
|
51
|
+
deleted = status.select { |l| l[0] == "D" || l[1] == "D" }.map { |l| l[3..] }
|
|
52
|
+
|
|
53
|
+
lines = []
|
|
54
|
+
lines << "Modified: #{modified.join(', ')}" if modified.any?
|
|
55
|
+
lines << "Added: #{added.join(', ')}" if added.any?
|
|
56
|
+
lines << "Deleted: #{deleted.join(', ')}" if deleted.any?
|
|
57
|
+
lines.any? ? "Working tree:\n#{lines.join("\n")}" : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def diff_section
|
|
61
|
+
lines = []
|
|
62
|
+
lines << "Staged:\n#{truncate(staged_diff)}" if staged_diff.any?
|
|
63
|
+
lines << "Unstaged:\n#{truncate(unstaged_diff)}" if unstaged_diff.any?
|
|
64
|
+
lines.join("\n\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def recent_commits_section
|
|
68
|
+
"Recent commits:\n#{recent_commits.join("\n")}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def truncate(lines)
|
|
72
|
+
if lines.size > MAX_DIFF_LINES
|
|
73
|
+
lines.first(MAX_DIFF_LINES).join + "\n... (#{lines.size - MAX_DIFF_LINES} more lines)"
|
|
74
|
+
else
|
|
75
|
+
lines.join
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/elelem/terminal.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Elelem
|
|
|
7
7
|
@modes = modes
|
|
8
8
|
@providers = providers
|
|
9
9
|
@env_vars = env_vars
|
|
10
|
+
@spinner_thread = nil
|
|
10
11
|
setup_completion
|
|
11
12
|
end
|
|
12
13
|
|
|
@@ -15,13 +16,28 @@ module Elelem
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def say(message)
|
|
19
|
+
stop_spinner
|
|
18
20
|
$stdout.puts message
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def write(message)
|
|
24
|
+
stop_spinner
|
|
22
25
|
$stdout.print message
|
|
23
26
|
end
|
|
24
27
|
|
|
28
|
+
def waiting
|
|
29
|
+
@spinner_thread = Thread.new do
|
|
30
|
+
frames = %w[| / - \\]
|
|
31
|
+
i = 0
|
|
32
|
+
loop do
|
|
33
|
+
$stdout.print "\r#{frames[i % frames.length]} "
|
|
34
|
+
$stdout.flush
|
|
35
|
+
i += 1
|
|
36
|
+
sleep 0.1
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
25
41
|
def select(question, options, &block)
|
|
26
42
|
CLI::UI::Prompt.ask(question) do |handler|
|
|
27
43
|
options.each do |option|
|
|
@@ -32,6 +48,14 @@ module Elelem
|
|
|
32
48
|
|
|
33
49
|
private
|
|
34
50
|
|
|
51
|
+
def stop_spinner
|
|
52
|
+
return unless @spinner_thread
|
|
53
|
+
|
|
54
|
+
@spinner_thread.kill
|
|
55
|
+
@spinner_thread = nil
|
|
56
|
+
$stdout.print "\r \r"
|
|
57
|
+
end
|
|
58
|
+
|
|
35
59
|
def setup_completion
|
|
36
60
|
Reline.autocompletion = true
|
|
37
61
|
Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
|
data/lib/elelem/toolbox.rb
CHANGED
|
@@ -49,6 +49,7 @@ module Elelem
|
|
|
49
49
|
|
|
50
50
|
def initialize
|
|
51
51
|
@tools_by_name = {}
|
|
52
|
+
@tool_permissions = {}
|
|
52
53
|
@tools = { read: [], write: [], execute: [] }
|
|
53
54
|
add_tool(eval_tool(binding), :execute)
|
|
54
55
|
add_tool(EXEC_TOOL, :execute)
|
|
@@ -59,22 +60,31 @@ module Elelem
|
|
|
59
60
|
add_tool(WRITE_TOOL, :write)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
def add_tool(tool,
|
|
63
|
-
@tools[
|
|
63
|
+
def add_tool(tool, permission)
|
|
64
|
+
@tools[permission] << tool
|
|
64
65
|
@tools_by_name[tool.name] = tool
|
|
66
|
+
@tool_permissions[tool.name] = permission
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
|
|
68
70
|
add_tool(Tool.build(name, description, properties, required, &block), mode)
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
def tools_for(
|
|
72
|
-
Array(
|
|
73
|
+
def tools_for(permissions)
|
|
74
|
+
Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
|
|
73
75
|
end
|
|
74
76
|
|
|
75
|
-
def run_tool(name, args)
|
|
77
|
+
def run_tool(name, args, permissions: [])
|
|
76
78
|
resolved_name = TOOL_ALIASES.fetch(name, name)
|
|
77
|
-
@tools_by_name[resolved_name]
|
|
79
|
+
tool = @tools_by_name[resolved_name]
|
|
80
|
+
return { error: "Unknown tool", name: name, args: args } unless tool
|
|
81
|
+
|
|
82
|
+
tool_permission = @tool_permissions[resolved_name]
|
|
83
|
+
unless Array(permissions).include?(tool_permission)
|
|
84
|
+
return { error: "Tool '#{resolved_name}' not available in current mode", name: name }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
tool.call(args)
|
|
78
88
|
rescue => error
|
|
79
89
|
{ error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
|
|
80
90
|
end
|
data/lib/elelem/version.rb
CHANGED
data/lib/elelem.rb
CHANGED
|
@@ -17,6 +17,7 @@ require "timeout"
|
|
|
17
17
|
require_relative "elelem/agent"
|
|
18
18
|
require_relative "elelem/application"
|
|
19
19
|
require_relative "elelem/conversation"
|
|
20
|
+
require_relative "elelem/git_context"
|
|
20
21
|
require_relative "elelem/terminal"
|
|
21
22
|
require_relative "elelem/tool"
|
|
22
23
|
require_relative "elelem/toolbox"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: elelem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mo khan
|
|
@@ -214,6 +214,7 @@ files:
|
|
|
214
214
|
- lib/elelem/agent.rb
|
|
215
215
|
- lib/elelem/application.rb
|
|
216
216
|
- lib/elelem/conversation.rb
|
|
217
|
+
- lib/elelem/git_context.rb
|
|
217
218
|
- lib/elelem/system_prompt.erb
|
|
218
219
|
- lib/elelem/terminal.rb
|
|
219
220
|
- lib/elelem/tool.rb
|
|
@@ -241,7 +242,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
241
242
|
- !ruby/object:Gem::Version
|
|
242
243
|
version: 3.3.11
|
|
243
244
|
requirements: []
|
|
244
|
-
rubygems_version: 3.
|
|
245
|
+
rubygems_version: 3.7.2
|
|
245
246
|
specification_version: 4
|
|
246
247
|
summary: A minimal coding agent for LLMs.
|
|
247
248
|
test_files: []
|