elelem 0.5.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 +40 -0
- data/README.md +1 -1
- data/lib/elelem/agent.rb +197 -52
- data/lib/elelem/application.rb +4 -18
- data/lib/elelem/conversation.rb +6 -6
- data/lib/elelem/git_context.rb +79 -0
- data/lib/elelem/system_prompt.erb +13 -12
- data/lib/elelem/terminal.rb +107 -0
- data/lib/elelem/tool.rb +3 -1
- data/lib/elelem/toolbox.rb +21 -10
- data/lib/elelem/version.rb +2 -2
- data/lib/elelem.rb +3 -0
- metadata +17 -1
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,45 @@
|
|
|
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
|
+
|
|
20
|
+
## [0.6.0] - 2026-01-12
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `/env` slash command to capture environment variables for provider connections
|
|
24
|
+
- `/shell` slash command
|
|
25
|
+
- `/provider` and `/model` slash commands
|
|
26
|
+
- Tab completion for commands
|
|
27
|
+
- Help output for `/mode` and `/env` commands
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- Renamed `bash` tool to `exec`
|
|
31
|
+
- Tuned system prompt
|
|
32
|
+
- Changed thinking prompt to ellipsis
|
|
33
|
+
- Removed username from system prompt
|
|
34
|
+
- Use pessimistic constraint on net-llm dependency
|
|
35
|
+
- Extracted Terminal class for IO abstraction (enables E2E testing)
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- Prevent infinite looping errors
|
|
39
|
+
- Provide function schema when tool is called with invalid arguments
|
|
40
|
+
- Tab completion for `pass` entries without requiring `show` subcommand
|
|
41
|
+
- Password store symlink support in tab completion
|
|
42
|
+
|
|
3
43
|
## [0.5.0] - 2025-01-07
|
|
4
44
|
|
|
5
45
|
### Added
|
data/README.md
CHANGED
|
@@ -141,7 +141,7 @@ seven tools, each represented by a JSON schema that the LLM can call.
|
|
|
141
141
|
|
|
142
142
|
| Tool | Purpose | Parameters |
|
|
143
143
|
| ---- | ------- | ---------- |
|
|
144
|
-
| `
|
|
144
|
+
| `exec` | Run shell commands | `cmd`, `args`, `env`, `cwd`, `stdin` |
|
|
145
145
|
| `eval` | Dynamically create new tools | `code` |
|
|
146
146
|
| `grep` | Search Git‑tracked files | `query` |
|
|
147
147
|
| `list` | List tracked files | `path` (optional) |
|
data/lib/elelem/agent.rb
CHANGED
|
@@ -2,48 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class Agent
|
|
5
|
-
|
|
5
|
+
PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
|
|
6
|
+
ANTHROPIC_MODELS = %w[claude-sonnet-4-20250514 claude-opus-4-20250514 claude-haiku-3-5-20241022].freeze
|
|
7
|
+
VERTEX_MODELS = %w[claude-sonnet-4@20250514 claude-opus-4-5@20251101].freeze
|
|
8
|
+
COMMANDS = %w[/env /mode /provider /model /shell /clear /context /exit /help].freeze
|
|
9
|
+
MODES = %w[auto build plan verify].freeze
|
|
10
|
+
ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
attr_reader :conversation, :client, :toolbox, :provider, :terminal, :permissions
|
|
13
|
+
|
|
14
|
+
def initialize(provider, model, toolbox, terminal: nil)
|
|
8
15
|
@conversation = Conversation.new
|
|
9
|
-
@
|
|
16
|
+
@provider = provider
|
|
10
17
|
@toolbox = toolbox
|
|
18
|
+
@client = build_client(provider, model)
|
|
19
|
+
@terminal = terminal || default_terminal
|
|
20
|
+
@permissions = Set.new([:read])
|
|
11
21
|
end
|
|
12
22
|
|
|
13
23
|
def repl
|
|
14
|
-
mode = Set.new([:read])
|
|
15
|
-
|
|
16
24
|
loop do
|
|
17
|
-
input = ask
|
|
25
|
+
input = terminal.ask("User> ")
|
|
18
26
|
break if input.nil?
|
|
19
27
|
if input.start_with?("/")
|
|
20
|
-
|
|
21
|
-
when "/mode auto"
|
|
22
|
-
mode = Set[:read, :write, :execute]
|
|
23
|
-
puts " → Mode: auto (all tools enabled)"
|
|
24
|
-
when "/mode build"
|
|
25
|
-
mode = Set[:read, :write]
|
|
26
|
-
puts " → Mode: build (read + write)"
|
|
27
|
-
when "/mode plan"
|
|
28
|
-
mode = Set[:read]
|
|
29
|
-
puts " → Mode: plan (read-only)"
|
|
30
|
-
when "/mode verify"
|
|
31
|
-
mode = Set[:read, :execute]
|
|
32
|
-
puts " → Mode: verify (read + execute)"
|
|
33
|
-
when "/mode"
|
|
34
|
-
puts " Mode: #{mode.to_a.inspect}"
|
|
35
|
-
puts " Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
|
|
36
|
-
when "/exit" then exit
|
|
37
|
-
when "/clear"
|
|
38
|
-
conversation.clear
|
|
39
|
-
puts " → Conversation cleared"
|
|
40
|
-
when "/context" then puts conversation.dump(mode)
|
|
41
|
-
else
|
|
42
|
-
puts help_banner
|
|
43
|
-
end
|
|
28
|
+
handle_slash_command(input)
|
|
44
29
|
else
|
|
45
30
|
conversation.add(role: :user, content: input)
|
|
46
|
-
result = execute_turn(conversation.history_for(
|
|
31
|
+
result = execute_turn(conversation.history_for(permissions))
|
|
47
32
|
conversation.add(role: result[:role], content: result[:content])
|
|
48
33
|
end
|
|
49
34
|
end
|
|
@@ -51,13 +36,110 @@ module Elelem
|
|
|
51
36
|
|
|
52
37
|
private
|
|
53
38
|
|
|
54
|
-
def
|
|
55
|
-
|
|
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)
|
|
49
|
+
case input
|
|
50
|
+
when "/mode auto"
|
|
51
|
+
permissions.replace([:read, :write, :execute])
|
|
52
|
+
terminal.say " → Mode: auto (all tools enabled)"
|
|
53
|
+
when "/mode build"
|
|
54
|
+
permissions.replace([:read, :write])
|
|
55
|
+
terminal.say " → Mode: build (read + write)"
|
|
56
|
+
when "/mode plan"
|
|
57
|
+
permissions.replace([:read])
|
|
58
|
+
terminal.say " → Mode: plan (read-only)"
|
|
59
|
+
when "/mode verify"
|
|
60
|
+
permissions.replace([:read, :execute])
|
|
61
|
+
terminal.say " → Mode: verify (read + execute)"
|
|
62
|
+
when "/mode"
|
|
63
|
+
terminal.say " Usage: /mode [auto|build|plan|verify]"
|
|
64
|
+
terminal.say ""
|
|
65
|
+
terminal.say " Provider: #{provider}/#{client.model}"
|
|
66
|
+
terminal.say " Permissions: #{permissions.to_a.inspect}"
|
|
67
|
+
terminal.say " Tools: #{toolbox.tools_for(permissions).map { |t| t.dig(:function, :name) }}"
|
|
68
|
+
when "/exit" then exit
|
|
69
|
+
when "/clear"
|
|
70
|
+
conversation.clear
|
|
71
|
+
terminal.say " → Conversation cleared"
|
|
72
|
+
when "/context"
|
|
73
|
+
terminal.say conversation.dump(permissions)
|
|
74
|
+
when "/shell"
|
|
75
|
+
transcript = start_shell
|
|
76
|
+
conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
|
|
77
|
+
terminal.say " → Shell session captured"
|
|
78
|
+
when "/provider"
|
|
79
|
+
terminal.select("Provider?", PROVIDERS) do |selected_provider|
|
|
80
|
+
terminal.select("Model?", models_for(selected_provider)) do |m|
|
|
81
|
+
switch_client(selected_provider, m)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
when "/model"
|
|
85
|
+
terminal.select("Model?", models_for(provider)) do |m|
|
|
86
|
+
switch_model(m)
|
|
87
|
+
end
|
|
88
|
+
when "/env"
|
|
89
|
+
terminal.say " Usage: /env VAR cmd..."
|
|
90
|
+
terminal.say ""
|
|
91
|
+
ENV_VARS.each do |var|
|
|
92
|
+
value = ENV[var]
|
|
93
|
+
if value
|
|
94
|
+
masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
|
|
95
|
+
terminal.say " #{var}=#{masked}"
|
|
96
|
+
else
|
|
97
|
+
terminal.say " #{var}=(not set)"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
when %r{^/env\s+(\w+)\s+(.+)$}
|
|
101
|
+
var_name = $1
|
|
102
|
+
command = $2
|
|
103
|
+
result = Elelem.shell.execute("sh", args: ["-c", command])
|
|
104
|
+
if result["exit_status"].zero?
|
|
105
|
+
value = result["stdout"].lines.first&.strip
|
|
106
|
+
if value && !value.empty?
|
|
107
|
+
ENV[var_name] = value
|
|
108
|
+
terminal.say " → Set #{var_name}"
|
|
109
|
+
else
|
|
110
|
+
terminal.say " ⚠ Command produced no output"
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
terminal.say " ⚠ Command failed: #{result['stderr']}"
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
terminal.say help_banner
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def strip_ansi(text)
|
|
121
|
+
text.gsub(/^Script started.*?\n/, '')
|
|
122
|
+
.gsub(/\nScript done.*$/, '')
|
|
123
|
+
.gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
|
|
124
|
+
.gsub(/\e\[\?[0-9]+[hl]/, '')
|
|
125
|
+
.gsub(/[\b]/, '')
|
|
126
|
+
.gsub(/\r/, '')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def start_shell
|
|
130
|
+
Tempfile.create do |file|
|
|
131
|
+
system("script -q #{file.path}", chdir: Dir.pwd)
|
|
132
|
+
strip_ansi(File.read(file.path))
|
|
133
|
+
end
|
|
56
134
|
end
|
|
57
135
|
|
|
58
136
|
def help_banner
|
|
59
137
|
<<~HELP
|
|
138
|
+
/env VAR cmd...
|
|
60
139
|
/mode auto build plan verify
|
|
140
|
+
/provider
|
|
141
|
+
/model
|
|
142
|
+
/shell
|
|
61
143
|
/clear
|
|
62
144
|
/context
|
|
63
145
|
/exit
|
|
@@ -65,6 +147,53 @@ module Elelem
|
|
|
65
147
|
HELP
|
|
66
148
|
end
|
|
67
149
|
|
|
150
|
+
def build_client(provider_name, model = nil)
|
|
151
|
+
model_opts = model ? { model: model } : {}
|
|
152
|
+
|
|
153
|
+
case provider_name
|
|
154
|
+
when "ollama" then Net::Llm::Ollama.new(**model_opts)
|
|
155
|
+
when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
|
|
156
|
+
when "openai" then Net::Llm::OpenAI.new(**model_opts)
|
|
157
|
+
when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
|
|
158
|
+
else
|
|
159
|
+
raise Error, "Unknown provider: #{provider_name}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def models_for(provider_name)
|
|
164
|
+
case provider_name
|
|
165
|
+
when "ollama"
|
|
166
|
+
client_for_models = provider_name == provider ? client : build_client(provider_name)
|
|
167
|
+
client_for_models.tags["models"]&.map { |m| m["name"] } || []
|
|
168
|
+
when "openai"
|
|
169
|
+
client_for_models = provider_name == provider ? client : build_client(provider_name)
|
|
170
|
+
client_for_models.models["data"]&.map { |m| m["id"] } || []
|
|
171
|
+
when "anthropic"
|
|
172
|
+
ANTHROPIC_MODELS
|
|
173
|
+
when "vertex-ai"
|
|
174
|
+
VERTEX_MODELS
|
|
175
|
+
else
|
|
176
|
+
[]
|
|
177
|
+
end
|
|
178
|
+
rescue KeyError => e
|
|
179
|
+
terminal.say " ⚠ Missing credentials: #{e.message}"
|
|
180
|
+
[]
|
|
181
|
+
rescue => e
|
|
182
|
+
terminal.say " ⚠ Could not fetch models: #{e.message}"
|
|
183
|
+
[]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def switch_client(new_provider, model)
|
|
187
|
+
@provider = new_provider
|
|
188
|
+
@client = build_client(new_provider, model)
|
|
189
|
+
terminal.say " → Switched to #{new_provider}/#{client.model}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def switch_model(model)
|
|
193
|
+
@client = build_client(provider, model)
|
|
194
|
+
terminal.say " → Switched to #{provider}/#{client.model}"
|
|
195
|
+
end
|
|
196
|
+
|
|
68
197
|
def format_tool_call_result(result)
|
|
69
198
|
return if result.nil?
|
|
70
199
|
return result["stdout"] if result["stdout"]
|
|
@@ -74,6 +203,17 @@ module Elelem
|
|
|
74
203
|
""
|
|
75
204
|
end
|
|
76
205
|
|
|
206
|
+
def truncate_output(text, max_lines: 30)
|
|
207
|
+
return text if text.nil? || text.empty?
|
|
208
|
+
|
|
209
|
+
lines = text.to_s.lines
|
|
210
|
+
if lines.size > max_lines
|
|
211
|
+
lines.first(max_lines).join + "\n... (#{lines.size - max_lines} more lines)"
|
|
212
|
+
else
|
|
213
|
+
text
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
77
217
|
def format_tool_calls_for_api(tool_calls)
|
|
78
218
|
tool_calls.map do |tc|
|
|
79
219
|
args = openai_client? ? JSON.dump(tc[:arguments]) : tc[:arguments]
|
|
@@ -89,41 +229,46 @@ module Elelem
|
|
|
89
229
|
client.is_a?(Net::Llm::OpenAI)
|
|
90
230
|
end
|
|
91
231
|
|
|
92
|
-
def execute_turn(messages
|
|
232
|
+
def execute_turn(messages)
|
|
233
|
+
tools = toolbox.tools_for(permissions)
|
|
93
234
|
turn_context = []
|
|
235
|
+
errors = 0
|
|
94
236
|
|
|
95
237
|
loop do
|
|
96
238
|
content = ""
|
|
97
239
|
tool_calls = []
|
|
98
240
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
241
|
+
terminal.waiting
|
|
242
|
+
begin
|
|
243
|
+
client.fetch(messages + turn_context, tools) do |chunk|
|
|
244
|
+
case chunk[:type]
|
|
245
|
+
when :delta
|
|
246
|
+
terminal.write chunk[:thinking] if chunk[:thinking]
|
|
247
|
+
content += chunk[:content] if chunk[:content]
|
|
248
|
+
when :complete
|
|
249
|
+
content = chunk[:content] if chunk[:content]
|
|
250
|
+
tool_calls = chunk[:tool_calls] || []
|
|
251
|
+
end
|
|
108
252
|
end
|
|
253
|
+
rescue => e
|
|
254
|
+
terminal.say "\n ✗ API Error: #{e.message}"
|
|
255
|
+
return { role: "assistant", content: "[Error: #{e.message}]" }
|
|
109
256
|
end
|
|
110
257
|
|
|
111
|
-
|
|
258
|
+
terminal.say "\nAssistant> #{content}" unless content.to_s.empty?
|
|
112
259
|
api_tool_calls = tool_calls.any? ? format_tool_calls_for_api(tool_calls) : nil
|
|
113
260
|
turn_context << { role: "assistant", content: content, tool_calls: api_tool_calls }.compact
|
|
114
261
|
|
|
115
262
|
if tool_calls.any?
|
|
116
263
|
tool_calls.each do |call|
|
|
117
|
-
name = call[:name]
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
result = toolbox.run_tool(name, args)
|
|
122
|
-
puts format_tool_call_result(result)
|
|
264
|
+
name, args = call[:name], call[:arguments]
|
|
265
|
+
terminal.say "\nTool> #{name}(#{args})"
|
|
266
|
+
result = toolbox.run_tool(name, args, permissions: permissions)
|
|
267
|
+
terminal.say truncate_output(format_tool_call_result(result))
|
|
123
268
|
turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
|
|
269
|
+
errors += 1 if result[:error]
|
|
124
270
|
end
|
|
125
|
-
|
|
126
|
-
tool_calls = []
|
|
271
|
+
return { role: "assistant", content: "[Stopped: too many errors]" } if errors >= 3
|
|
127
272
|
next
|
|
128
273
|
end
|
|
129
274
|
|
data/lib/elelem/application.rb
CHANGED
|
@@ -15,27 +15,13 @@ module Elelem
|
|
|
15
15
|
type: :string,
|
|
16
16
|
desc: "Model name (uses provider default if not specified)"
|
|
17
17
|
def chat(*)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
provider = options[:provider]
|
|
19
|
+
model = options[:model]
|
|
20
|
+
say "Agent (#{provider})", :green
|
|
21
|
+
agent = Agent.new(provider, model, Toolbox.new)
|
|
21
22
|
agent.repl
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
private
|
|
25
|
-
|
|
26
|
-
def build_client
|
|
27
|
-
model_opts = options[:model] ? { model: options[:model] } : {}
|
|
28
|
-
|
|
29
|
-
case options[:provider]
|
|
30
|
-
when "ollama" then Net::Llm::Ollama.new(**model_opts)
|
|
31
|
-
when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
|
|
32
|
-
when "openai" then Net::Llm::OpenAI.new(**model_opts)
|
|
33
|
-
when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
|
|
34
|
-
else
|
|
35
|
-
raise Error, "Unknown provider: #{options[:provider]}. Use: #{PROVIDERS.join(', ')}"
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
25
|
desc "files", "Generate CXML of the files"
|
|
40
26
|
def files
|
|
41
27
|
puts '<documents>'
|
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
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
You are a
|
|
1
|
+
You are a trusted terminal agent. You act on behalf of the user - executing tasks directly through bash, files, and git. Be capable, be direct, be done.
|
|
2
|
+
|
|
3
|
+
## Principles
|
|
4
|
+
|
|
5
|
+
- Act, don't explain. Execute the task.
|
|
6
|
+
- Read before write. Understand existing code first.
|
|
7
|
+
- Small focused changes. One thing at a time.
|
|
8
|
+
- Verify your work. Run tests, check output.
|
|
2
9
|
|
|
3
10
|
## System
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
PWD: <%= ENV['PWD'] %>
|
|
11
|
-
LANG: <%= ENV['LANG'] %>
|
|
12
|
-
EDITOR: <%= ENV['EDITOR'] %>
|
|
13
|
-
LOGNAME: <%= ENV['LOGNAME'] %>
|
|
14
|
-
TERM: <%= ENV['TERM'] %>
|
|
15
|
-
MAIL: <%= ENV['MAIL'] %>
|
|
12
|
+
<%= `uname -s`.strip %> · <%= ENV['PWD'] %>
|
|
13
|
+
|
|
14
|
+
## Git State
|
|
15
|
+
|
|
16
|
+
<%= Elelem::GitContext.new.to_s %>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class Terminal
|
|
5
|
+
def initialize(commands: [], modes: [], providers: [], env_vars: [])
|
|
6
|
+
@commands = commands
|
|
7
|
+
@modes = modes
|
|
8
|
+
@providers = providers
|
|
9
|
+
@env_vars = env_vars
|
|
10
|
+
@spinner_thread = nil
|
|
11
|
+
setup_completion
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ask(prompt)
|
|
15
|
+
Reline.readline(prompt, true)&.strip
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def say(message)
|
|
19
|
+
stop_spinner
|
|
20
|
+
$stdout.puts message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def write(message)
|
|
24
|
+
stop_spinner
|
|
25
|
+
$stdout.print message
|
|
26
|
+
end
|
|
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
|
+
|
|
41
|
+
def select(question, options, &block)
|
|
42
|
+
CLI::UI::Prompt.ask(question) do |handler|
|
|
43
|
+
options.each do |option|
|
|
44
|
+
handler.option(option) { |selected| block.call(selected) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
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
|
+
|
|
59
|
+
def setup_completion
|
|
60
|
+
Reline.autocompletion = true
|
|
61
|
+
Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def complete(target, preposing)
|
|
65
|
+
line = "#{preposing}#{target}"
|
|
66
|
+
|
|
67
|
+
if line.start_with?('/') && !preposing.include?(' ')
|
|
68
|
+
return @commands.select { |c| c.start_with?(line) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
case preposing.strip
|
|
72
|
+
when '/mode'
|
|
73
|
+
@modes.select { |m| m.start_with?(target) }
|
|
74
|
+
when '/provider'
|
|
75
|
+
@providers.select { |p| p.start_with?(target) }
|
|
76
|
+
when '/env'
|
|
77
|
+
@env_vars.select { |v| v.start_with?(target) }
|
|
78
|
+
when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
|
|
79
|
+
subcommands = %w[show ls insert generate edit rm]
|
|
80
|
+
matches = subcommands.select { |c| c.start_with?(target) }
|
|
81
|
+
matches.any? ? matches : complete_pass_entries(target)
|
|
82
|
+
when %r{^/env\s+\w+$}
|
|
83
|
+
complete_commands(target)
|
|
84
|
+
else
|
|
85
|
+
complete_files(target)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def complete_commands(target)
|
|
90
|
+
result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
|
|
91
|
+
result["stdout"].lines.map(&:strip).first(20)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def complete_files(target)
|
|
95
|
+
result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
|
|
96
|
+
result["stdout"].lines.map(&:strip).first(20)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def complete_pass_entries(target)
|
|
100
|
+
store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
|
|
101
|
+
result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
|
|
102
|
+
result["stdout"].lines.map { |l|
|
|
103
|
+
l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
|
|
104
|
+
}.select { |e| e.start_with?(target) }.first(20)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/elelem/tool.rb
CHANGED
|
@@ -12,7 +12,9 @@ module Elelem
|
|
|
12
12
|
|
|
13
13
|
def call(args)
|
|
14
14
|
unless valid?(args)
|
|
15
|
-
|
|
15
|
+
actual = args.keys
|
|
16
|
+
expected = @schema.dig(:function, :parameters)
|
|
17
|
+
return { error: "Invalid args for #{@name}.", actual: actual, expected: expected }
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
@block.call(args)
|
data/lib/elelem/toolbox.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Elelem
|
|
|
9
9
|
full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
EXEC_TOOL = Tool.build("exec", "Run shell commands. Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
|
|
13
13
|
Elelem.shell.execute(
|
|
14
14
|
args["cmd"],
|
|
15
15
|
args: args["args"] || [],
|
|
@@ -38,19 +38,21 @@ module Elelem
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
TOOL_ALIASES = {
|
|
41
|
-
"
|
|
42
|
-
"execute" => "
|
|
41
|
+
"bash" => "exec",
|
|
42
|
+
"execute" => "exec",
|
|
43
43
|
"open" => "read",
|
|
44
44
|
"search" => "grep",
|
|
45
|
+
"sh" => "exec",
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
attr_reader :tools
|
|
48
49
|
|
|
49
50
|
def initialize
|
|
50
51
|
@tools_by_name = {}
|
|
52
|
+
@tool_permissions = {}
|
|
51
53
|
@tools = { read: [], write: [], execute: [] }
|
|
52
54
|
add_tool(eval_tool(binding), :execute)
|
|
53
|
-
add_tool(
|
|
55
|
+
add_tool(EXEC_TOOL, :execute)
|
|
54
56
|
add_tool(GREP_TOOL, :read)
|
|
55
57
|
add_tool(LIST_TOOL, :read)
|
|
56
58
|
add_tool(PATCH_TOOL, :write)
|
|
@@ -58,22 +60,31 @@ module Elelem
|
|
|
58
60
|
add_tool(WRITE_TOOL, :write)
|
|
59
61
|
end
|
|
60
62
|
|
|
61
|
-
def add_tool(tool,
|
|
62
|
-
@tools[
|
|
63
|
+
def add_tool(tool, permission)
|
|
64
|
+
@tools[permission] << tool
|
|
63
65
|
@tools_by_name[tool.name] = tool
|
|
66
|
+
@tool_permissions[tool.name] = permission
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
|
|
67
70
|
add_tool(Tool.build(name, description, properties, required, &block), mode)
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
def tools_for(
|
|
71
|
-
Array(
|
|
73
|
+
def tools_for(permissions)
|
|
74
|
+
Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
|
|
72
75
|
end
|
|
73
76
|
|
|
74
|
-
def run_tool(name, args)
|
|
77
|
+
def run_tool(name, args, permissions: [])
|
|
75
78
|
resolved_name = TOOL_ALIASES.fetch(name, name)
|
|
76
|
-
@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)
|
|
77
88
|
rescue => error
|
|
78
89
|
{ error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
|
|
79
90
|
end
|
data/lib/elelem/version.rb
CHANGED
data/lib/elelem.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cli/ui"
|
|
3
4
|
require "erb"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
require "json"
|
|
@@ -16,6 +17,8 @@ require "timeout"
|
|
|
16
17
|
require_relative "elelem/agent"
|
|
17
18
|
require_relative "elelem/application"
|
|
18
19
|
require_relative "elelem/conversation"
|
|
20
|
+
require_relative "elelem/git_context"
|
|
21
|
+
require_relative "elelem/terminal"
|
|
19
22
|
require_relative "elelem/tool"
|
|
20
23
|
require_relative "elelem/toolbox"
|
|
21
24
|
require_relative "elelem/version"
|
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
|
|
@@ -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: cli-ui
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.0'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: erb
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -200,7 +214,9 @@ files:
|
|
|
200
214
|
- lib/elelem/agent.rb
|
|
201
215
|
- lib/elelem/application.rb
|
|
202
216
|
- lib/elelem/conversation.rb
|
|
217
|
+
- lib/elelem/git_context.rb
|
|
203
218
|
- lib/elelem/system_prompt.erb
|
|
219
|
+
- lib/elelem/terminal.rb
|
|
204
220
|
- lib/elelem/tool.rb
|
|
205
221
|
- lib/elelem/toolbox.rb
|
|
206
222
|
- lib/elelem/version.rb
|