elelem 0.6.0 → 0.8.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: a284087a47f32b75927bb0fc593aee12c376952f89af2ae61faa96b66810ae87
4
+ data.tar.gz: 138d5d8a0c110a1631c1a3b64024d2f82b5a80bfa1339244ef0d5bb932c2c626
5
5
  SHA512:
6
- metadata.gz: e7b58a5575c8065b1dd9bd020615dc6ec85660b8f5df431f01d28fca90d199608a845aae8e915fd6508c23817f98b2f8c5396e79595d293c1eedbe7f4e9146a7
7
- data.tar.gz: 6556959f36182acd496d99bc64001bb1ccdff79a95d14ec3116dec1f86a4da928fc35e5f8bc32e84b93161d3013f98f374c2f4188efe9374121e80829e2ca157
6
+ metadata.gz: b3baefa781ccfef8e2a4882a1b8f65779b672f45918a9e0a9926741dbb80ecfb57c4c182f9074ae71e73b29010d3c349a893f3bde388d7e5340d04a158ae3cde
7
+ data.tar.gz: d4d6d0609f1ef0fa9bc5e9bb67aec6085944706db8a31025a63299f3ea0519f202a4a3f4a2ea4dad647bcb1390a98eb3ea7b1f0ab2f6b16c87ec18f6f1c355e3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-01-14
4
+
5
+ ### Added
6
+ - `fetch` tool for HTTP GET requests (returns status and body)
7
+ - `search_engine` tool for DuckDuckGo Instant Answer API searches
8
+ - Tool aliases: `get`/`web` → `fetch`, `ddg`/`duckduckgo` → `search_engine`
9
+ - `net-hippie` and `cgi` dependencies for HTTP requests
10
+
11
+ ## [0.7.0] - 2026-01-14
12
+
13
+ ### Added
14
+ - ASCII spinner animation while waiting for LLM responses
15
+ - `Terminal#waiting` method with automatic cleanup on next output
16
+ - Decision-making principles in system prompt (prefer reversible actions, ask when uncertain)
17
+ - Mode enforcement tests
18
+
19
+ ### Changed
20
+ - Renamed internal `mode` concept to `permissions` for clarity (read/write/execute are permissions, plan/build/verify are modes)
21
+ - Refactored `Toolbox#run_tool` to accept `permissions:` parameter
22
+
23
+ ### Fixed
24
+ - **Security**: Mode restrictions now enforced at execution time, not just schema time
25
+ - Previously, LLMs could call tools outside their mode by guessing tool names
26
+ - Now `run_tool` validates the tool is allowed for the current permission set
27
+
3
28
  ## [0.6.0] - 2026-01-12
4
29
 
5
30
  ### 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) }
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Elelem
4
4
  class Toolbox
5
-
6
5
  READ_TOOL = Tool.build("read", "Read complete contents of a file. Requires exact file path.", { path: { type: "string" } }, ["path"]) do |args|
7
6
  path = args["path"]
8
7
  full_path = Pathname.new(path).expand_path
@@ -37,21 +36,42 @@ module Elelem
37
36
  { bytes_written: full_path.write(args["content"]) }
38
37
  end
39
38
 
39
+ FETCH_TOOL = Tool.build("fetch", "Fetch content from a URL. Returns status, headers, and body.", { url: { type: "string", description: "The URL to fetch" } }, ["url"]) do |args|
40
+ client = Net::Hippie::Client.new
41
+ response = client.get(args["url"])
42
+ { status: response.code.to_i, body: response.body }
43
+ end
44
+
45
+ WEB_SEARCH_TOOL = Tool.build("search_engine", "Search the web using DuckDuckGo. Returns raw API response.", { query: { type: "string", description: "The search query" } }, ["query"]) do |args|
46
+ query = CGI.escape(args["query"])
47
+ url = "https://api.duckduckgo.com/?q=#{query}&format=json&no_html=1"
48
+ client = Net::Hippie::Client.new
49
+ response = client.get(url)
50
+ JSON.parse(response.body)
51
+ end
52
+
40
53
  TOOL_ALIASES = {
41
54
  "bash" => "exec",
55
+ "duckduckgo" => "search_engine",
56
+ "ddg" => "search_engine",
42
57
  "execute" => "exec",
58
+ "get" => "fetch",
43
59
  "open" => "read",
44
60
  "search" => "grep",
45
61
  "sh" => "exec",
62
+ "web" => "fetch",
46
63
  }
47
64
 
48
65
  attr_reader :tools
49
66
 
50
67
  def initialize
51
68
  @tools_by_name = {}
69
+ @tool_permissions = {}
52
70
  @tools = { read: [], write: [], execute: [] }
53
71
  add_tool(eval_tool(binding), :execute)
72
+ add_tool(WEB_SEARCH_TOOL, :read)
54
73
  add_tool(EXEC_TOOL, :execute)
74
+ add_tool(FETCH_TOOL, :read)
55
75
  add_tool(GREP_TOOL, :read)
56
76
  add_tool(LIST_TOOL, :read)
57
77
  add_tool(PATCH_TOOL, :write)
@@ -59,22 +79,31 @@ module Elelem
59
79
  add_tool(WRITE_TOOL, :write)
60
80
  end
61
81
 
62
- def add_tool(tool, mode)
63
- @tools[mode] << tool
82
+ def add_tool(tool, permission)
83
+ @tools[permission] << tool
64
84
  @tools_by_name[tool.name] = tool
85
+ @tool_permissions[tool.name] = permission
65
86
  end
66
87
 
67
88
  def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
68
89
  add_tool(Tool.build(name, description, properties, required, &block), mode)
69
90
  end
70
91
 
71
- def tools_for(modes)
72
- Array(modes).map { |mode| tools[mode].map(&:to_h) }.flatten
92
+ def tools_for(permissions)
93
+ Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
73
94
  end
74
95
 
75
- def run_tool(name, args)
96
+ def run_tool(name, args, permissions: [])
76
97
  resolved_name = TOOL_ALIASES.fetch(name, name)
77
- @tools_by_name[resolved_name]&.call(args) || { error: "Unknown tool", name: name, args: args }
98
+ tool = @tools_by_name[resolved_name]
99
+ return { error: "Unknown tool", name: name, args: args } unless tool
100
+
101
+ tool_permission = @tool_permissions[resolved_name]
102
+ unless Array(permissions).include?(tool_permission)
103
+ return { error: "Tool '#{resolved_name}' not available in current mode", name: name }
104
+ end
105
+
106
+ tool.call(args)
78
107
  rescue => error
79
108
  { error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
80
109
  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.8.0"
5
5
  end
data/lib/elelem.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
3
4
  require "cli/ui"
4
5
  require "erb"
5
6
  require "fileutils"
6
7
  require "json"
7
8
  require "json-schema"
8
9
  require "logger"
10
+ require "net/hippie"
9
11
  require "net/llm"
10
12
  require "open3"
11
13
  require "pathname"
@@ -17,6 +19,7 @@ require "timeout"
17
19
  require_relative "elelem/agent"
18
20
  require_relative "elelem/application"
19
21
  require_relative "elelem/conversation"
22
+ require_relative "elelem/git_context"
20
23
  require_relative "elelem/terminal"
21
24
  require_relative "elelem/tool"
22
25
  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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cgi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: cli-ui
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +107,20 @@ dependencies:
93
107
  - - "~>"
94
108
  - !ruby/object:Gem::Version
95
109
  version: '1.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: net-hippie
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '1.0'
96
124
  - !ruby/object:Gem::Dependency
97
125
  name: net-llm
98
126
  requirement: !ruby/object:Gem::Requirement
@@ -214,6 +242,7 @@ files:
214
242
  - lib/elelem/agent.rb
215
243
  - lib/elelem/application.rb
216
244
  - lib/elelem/conversation.rb
245
+ - lib/elelem/git_context.rb
217
246
  - lib/elelem/system_prompt.erb
218
247
  - lib/elelem/terminal.rb
219
248
  - lib/elelem/tool.rb
@@ -241,7 +270,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
241
270
  - !ruby/object:Gem::Version
242
271
  version: 3.3.11
243
272
  requirements: []
244
- rubygems_version: 3.6.9
273
+ rubygems_version: 3.7.2
245
274
  specification_version: 4
246
275
  summary: A minimal coding agent for LLMs.
247
276
  test_files: []