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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a97a3be43a2528518770e2881fef86138b19ce9394601de922822f9748fe9c8
4
- data.tar.gz: e87dc58d17f701d9b2fa0a15b05b7f2fbad24a7f71b5836a47f01fe6b216df55
3
+ metadata.gz: 9f36b6e8749d5c12525548bc5cfd8b270c2bd5f7ada78855f7b4c55e23e3cb52
4
+ data.tar.gz: 8bf6ae1b937a7fbf5dc22e0b0b522e903803a6562644afa04cf2b00c8d28133b
5
5
  SHA512:
6
- metadata.gz: e7b58a5575c8065b1dd9bd020615dc6ec85660b8f5df431f01d28fca90d199608a845aae8e915fd6508c23817f98b2f8c5396e79595d293c1eedbe7f4e9146a7
7
- data.tar.gz: 6556959f36182acd496d99bc64001bb1ccdff79a95d14ec3116dec1f86a4da928fc35e5f8bc32e84b93161d3013f98f374c2f4188efe9374121e80829e2ca157
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 || Terminal.new(
20
- commands: COMMANDS,
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
- handle_command(input, mode)
28
+ handle_slash_command(input)
35
29
  else
36
30
  conversation.add(role: :user, content: input)
37
- result = execute_turn(conversation.history_for(mode), tools: toolbox.tools_for(mode))
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 handle_command(input, mode)
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
- mode.replace([:read, :write, :execute])
51
+ permissions.replace([:read, :write, :execute])
49
52
  terminal.say " → Mode: auto (all tools enabled)"
50
53
  when "/mode build"
51
- mode.replace([:read, :write])
54
+ permissions.replace([:read, :write])
52
55
  terminal.say " → Mode: build (read + write)"
53
56
  when "/mode plan"
54
- mode.replace([:read])
57
+ permissions.replace([:read])
55
58
  terminal.say " → Mode: plan (read-only)"
56
59
  when "/mode verify"
57
- mode.replace([:read, :execute])
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 " Mode: #{mode.to_a.inspect}"
64
- terminal.say " Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
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(mode)
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
- models = models_for(selected_provider)
78
- if models.empty?
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
- models = models_for(provider)
88
- if models.empty?
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, tools:)
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.write "Thinking... "
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]
@@ -8,9 +8,9 @@ module Elelem
8
8
  @items = items
9
9
  end
10
10
 
11
- def history_for(mode)
11
+ def history_for(permissions)
12
12
  history = @items.dup
13
- history[0] = { role: "system", content: system_prompt_for(mode) }
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(mode)
34
- JSON.pretty_generate(history_for(mode))
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(mode)
43
+ def system_prompt_for(permissions)
44
44
  base = system_prompt
45
45
 
46
- case mode.sort
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
@@ -10,3 +10,7 @@ You are a trusted terminal agent. You act on behalf of the user - executing task
10
10
  ## System
11
11
 
12
12
  <%= `uname -s`.strip %> · <%= ENV['PWD'] %>
13
+
14
+ ## Git State
15
+
16
+ <%= Elelem::GitContext.new.to_s %>
@@ -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) }
@@ -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, mode)
63
- @tools[mode] << tool
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(modes)
72
- Array(modes).map { |mode| tools[mode].map(&:to_h) }.flatten
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]&.call(args) || { error: "Unknown tool", name: name, args: args }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
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.6.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.6.9
245
+ rubygems_version: 3.7.2
245
246
  specification_version: 4
246
247
  summary: A minimal coding agent for LLMs.
247
248
  test_files: []