elelem 0.10.0 → 0.12.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 +8 -195
- data/exe/elelem +7 -1
- data/lib/elelem/agent.rb +68 -62
- data/lib/elelem/cli.rb +116 -0
- data/lib/elelem/commands.rb +26 -10
- data/lib/elelem/config.rb +17 -0
- data/lib/elelem/conversation.rb +1 -0
- data/lib/elelem/input.rb +51 -0
- data/lib/elelem/null_input.rb +8 -0
- data/lib/elelem/null_output.rb +7 -0
- data/lib/elelem/null_tool.rb +18 -0
- data/lib/elelem/output.rb +55 -0
- data/lib/elelem/plugins.rb +20 -27
- data/lib/elelem/prompts/default.erb +3 -0
- data/lib/elelem/registration.rb +37 -0
- data/lib/elelem/registry.rb +68 -0
- data/lib/elelem/repl.rb +34 -0
- data/lib/elelem/result.rb +27 -0
- data/lib/elelem/schema.rb +20 -0
- data/lib/elelem/session.rb +17 -0
- data/lib/elelem/slash_command.rb +18 -0
- data/lib/elelem/stub_provider.rb +15 -0
- data/lib/elelem/system_prompt.rb +12 -135
- data/lib/elelem/tool.rb +7 -12
- data/lib/elelem/toolbox.rb +40 -31
- data/lib/elelem/transcript.rb +26 -0
- data/lib/elelem/version.rb +2 -2
- data/lib/elelem.rb +28 -113
- metadata +48 -155
- data/CHANGELOG.md +0 -309
- data/lib/elelem/mcp/oauth.rb +0 -217
- data/lib/elelem/mcp/token_storage.rb +0 -60
- data/lib/elelem/mcp.rb +0 -243
- data/lib/elelem/net/claude.rb +0 -202
- data/lib/elelem/net/ollama.rb +0 -81
- data/lib/elelem/net/openai.rb +0 -88
- data/lib/elelem/net.rb +0 -13
- data/lib/elelem/permissions.rb +0 -45
- data/lib/elelem/plugins/builtins.rb +0 -96
- data/lib/elelem/plugins/edit.rb +0 -15
- data/lib/elelem/plugins/eval.rb +0 -20
- data/lib/elelem/plugins/execute.rb +0 -18
- data/lib/elelem/plugins/git.rb +0 -20
- data/lib/elelem/plugins/glob.rb +0 -13
- data/lib/elelem/plugins/grep.rb +0 -21
- data/lib/elelem/plugins/list.rb +0 -14
- data/lib/elelem/plugins/mcp.rb +0 -20
- data/lib/elelem/plugins/permissions.json +0 -6
- data/lib/elelem/plugins/read.rb +0 -21
- data/lib/elelem/plugins/task.rb +0 -14
- data/lib/elelem/plugins/tools.rb +0 -13
- data/lib/elelem/plugins/verify.rb +0 -47
- data/lib/elelem/plugins/write.rb +0 -34
- data/lib/elelem/plugins/zz_confirm.rb +0 -9
- data/lib/elelem/terminal.rb +0 -103
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4696869850fc38fba45a65a38aca74f8663fe9b8562deb7a987cbc71058221a
|
|
4
|
+
data.tar.gz: 8ec379df1537243659cfee997c02fde40912be780ace54b14bb0ff181f5fad64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 52e225e4b206091bcd1838cce432ee2391c648fa4f995a722dfefc2820985bb405e5e3574a972a8e31be260695eb368f3f6f3dcfa32f8cbeeb76b5fab6b1a2f3
|
|
7
|
+
data.tar.gz: c508bfedce1daf081011046ca77a13872af1bb133d161edfb944d7e4019e92222b15dc9aeb4f98376328e099a62e5ec89f2b67d2f595ed6ea6a58281a0a44589
|
data/README.md
CHANGED
|
@@ -1,77 +1,17 @@
|
|
|
1
1
|
# Elelem
|
|
2
2
|
|
|
3
|
-
Fast, correct, autonomous
|
|
3
|
+
Fast, correct, autonomous - pick two.
|
|
4
4
|
|
|
5
5
|
## Purpose
|
|
6
6
|
|
|
7
|
-
Elelem is a minimal coding
|
|
8
|
-
you write, edit, and manage code and plain-text files from the command line
|
|
9
|
-
by delegating work to an LLM. The agent exposes a simple text-based UI and a
|
|
10
|
-
set of built-in tools that give the LLM access to the local file system.
|
|
7
|
+
Elelem is a minimal coding harness. The agent exposes a simple text-based UI.
|
|
11
8
|
|
|
12
9
|
## Design Principles
|
|
13
10
|
|
|
14
|
-
* Unix philosophy
|
|
11
|
+
* Unix philosophy - simple, composable, minimal.
|
|
15
12
|
* Convention over configuration.
|
|
16
13
|
* No defensive checks or complexity beyond what is necessary.
|
|
17
14
|
* Assumes a mature, responsible LLM that behaves like a capable engineer.
|
|
18
|
-
* Optimised for my personal workflow and preferences.
|
|
19
|
-
* Efficient and minimal like *aider* – https://aider.chat/.
|
|
20
|
-
* UX similar to Claude Code – https://docs.claude.com/en/docs/claude-code/overview.
|
|
21
|
-
|
|
22
|
-
## System Assumptions
|
|
23
|
-
|
|
24
|
-
* Linux host with Alacritty, tmux, Bash, Vim.
|
|
25
|
-
* Runs inside a Git repository.
|
|
26
|
-
* Git is available and functional.
|
|
27
|
-
|
|
28
|
-
## Dependencies
|
|
29
|
-
|
|
30
|
-
Elelem relies on several external tools. Install the ones you need:
|
|
31
|
-
|
|
32
|
-
| Tool | Purpose | Install |
|
|
33
|
-
|------|---------|---------|
|
|
34
|
-
| [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
|
|
35
|
-
| [ctags](https://ctags.io/) | Repo map generation | `brew install universal-ctags` / `apt install universal-ctags` |
|
|
36
|
-
| [fd](https://github.com/sharkdp/fd) | File discovery | `brew install fd` / `apt install fd-find` |
|
|
37
|
-
| [git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
|
|
38
|
-
| [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
|
|
39
|
-
| [jq](https://jqlang.github.io/jq/) | JSON processing | `brew install jq` / `apt install jq` |
|
|
40
|
-
| [ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
|
|
41
|
-
| [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
|
|
42
|
-
|
|
43
|
-
**Required:** Git, Ollama (or another LLM provider)
|
|
44
|
-
|
|
45
|
-
**Recommended:** glow, jq, ctags, ripgrep, fd
|
|
46
|
-
|
|
47
|
-
**Optional:** ast-grep (for structural code search)
|
|
48
|
-
|
|
49
|
-
## Scope
|
|
50
|
-
|
|
51
|
-
Only plain-text and source-code files are supported. No binary handling,
|
|
52
|
-
sandboxing, or permission checks are performed - the LLM has full access.
|
|
53
|
-
|
|
54
|
-
## Configuration
|
|
55
|
-
|
|
56
|
-
Prefer convention over configuration. Add environment variables only after
|
|
57
|
-
repeated use proves their usefulness.
|
|
58
|
-
|
|
59
|
-
## UI Expectations
|
|
60
|
-
|
|
61
|
-
Keyboard-driven, minimal TUI. No mouse support or complex widgets.
|
|
62
|
-
|
|
63
|
-
## Coding Standards for the LLM
|
|
64
|
-
|
|
65
|
-
* No extra error handling unless essential.
|
|
66
|
-
* Keep methods short, single-purpose.
|
|
67
|
-
* Descriptive, conventional names.
|
|
68
|
-
* Use Ruby standard library where possible.
|
|
69
|
-
|
|
70
|
-
## Helpful Links
|
|
71
|
-
|
|
72
|
-
* https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
|
|
73
|
-
* https://www.anthropic.com/engineering/writing-tools-for-agents
|
|
74
|
-
* https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
|
|
75
15
|
|
|
76
16
|
## Installation
|
|
77
17
|
|
|
@@ -89,144 +29,17 @@ Start an interactive chat session:
|
|
|
89
29
|
elelem chat
|
|
90
30
|
```
|
|
91
31
|
|
|
92
|
-
|
|
32
|
+
## Tools
|
|
93
33
|
|
|
94
|
-
|
|
95
|
-
* `--model` – Override the default model for the selected provider.
|
|
34
|
+
Use the plugin API to add tools.
|
|
96
35
|
|
|
97
|
-
|
|
36
|
+
## Contributing
|
|
98
37
|
|
|
99
38
|
```bash
|
|
100
|
-
|
|
101
|
-
elelem chat
|
|
102
|
-
|
|
103
|
-
# Anthropic Claude
|
|
104
|
-
ANTHROPIC_API_KEY=sk-... elelem chat --provider anthropic
|
|
105
|
-
|
|
106
|
-
# OpenAI
|
|
107
|
-
OPENAI_API_KEY=sk-... elelem chat --provider openai
|
|
108
|
-
|
|
109
|
-
# VertexAI (uses gcloud ADC)
|
|
110
|
-
elelem chat --provider vertex-ai --model claude-sonnet-4@20250514
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
### Provider Configuration
|
|
114
|
-
|
|
115
|
-
Each provider reads its configuration from environment variables:
|
|
116
|
-
|
|
117
|
-
| Provider | Environment Variables |
|
|
118
|
-
|-------------|---------------------------------------------------|
|
|
119
|
-
| ollama | `OLLAMA_HOST` (default: localhost:11434) |
|
|
120
|
-
| anthropic | `ANTHROPIC_API_KEY` |
|
|
121
|
-
| openai | `OPENAI_API_KEY`, `OPENAI_BASE_URL` |
|
|
122
|
-
| vertex-ai | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION` |
|
|
123
|
-
|
|
124
|
-
## Features
|
|
125
|
-
|
|
126
|
-
* **Interactive REPL** – clean, streaming chat.
|
|
127
|
-
* **Toolbox** – file I/O and shell execution.
|
|
128
|
-
* **Streaming Responses** – output appears in real time.
|
|
129
|
-
* **Conversation History** – persists across turns; can be cleared.
|
|
130
|
-
* **Context Dump** – `/context` shows the current conversation state.
|
|
131
|
-
|
|
132
|
-
## Tools
|
|
133
|
-
|
|
134
|
-
Built-in tools available to the LLM:
|
|
135
|
-
|
|
136
|
-
| Tool | Purpose | Parameters |
|
|
137
|
-
| --------- | -------------------------- | ------------------------- |
|
|
138
|
-
| `read` | Read file contents | `path` |
|
|
139
|
-
| `write` | Write file | `path`, `content` |
|
|
140
|
-
| `edit` | Replace text in file | `path`, `old`, `new` |
|
|
141
|
-
| `execute` | Run shell command | `command` |
|
|
142
|
-
| `eval` | Execute Ruby code | `ruby` |
|
|
143
|
-
| `glob` | Find files by pattern | `pattern`, `path` |
|
|
144
|
-
| `grep` | Search file contents | `pattern`, `path`, `glob` |
|
|
145
|
-
| `list` | List directory | `path`, `recursive` |
|
|
146
|
-
| `git` | Run git command | `command`, `args` |
|
|
147
|
-
| `task` | Delegate to sub-agent | `prompt` |
|
|
148
|
-
| `verify` | Check syntax and run tests | `path` |
|
|
149
|
-
|
|
150
|
-
Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`; `ls` → `list`
|
|
151
|
-
|
|
152
|
-
## Plugins
|
|
153
|
-
|
|
154
|
-
Plugins extend elelem with custom tools and commands. They are loaded from:
|
|
155
|
-
- `lib/elelem/plugins/` (built-in)
|
|
156
|
-
- `~/.elelem/plugins/` (user global)
|
|
157
|
-
- `.elelem/plugins/` (project local)
|
|
158
|
-
|
|
159
|
-
### Writing a Plugin
|
|
160
|
-
|
|
161
|
-
```ruby
|
|
162
|
-
# ~/.elelem/plugins/hello.rb
|
|
163
|
-
Elelem::Plugins.register(:hello) do |agent|
|
|
164
|
-
# Add a tool
|
|
165
|
-
agent.toolbox.add("hello",
|
|
166
|
-
description: "Say hello",
|
|
167
|
-
params: { name: { type: "string" } },
|
|
168
|
-
required: ["name"]
|
|
169
|
-
) do |args|
|
|
170
|
-
{ message: "Hello, #{args["name"]}!" }
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Add a command
|
|
174
|
-
agent.commands.register("greet", description: "Greet the user") do
|
|
175
|
-
agent.terminal.say "Hello!"
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Add hooks
|
|
179
|
-
agent.toolbox.before("execute") { |args| puts "Running: #{args["command"]}" }
|
|
180
|
-
agent.toolbox.after("execute") { |args, result| puts "Exit: #{result[:exit_status]}" }
|
|
181
|
-
|
|
182
|
-
# Global hook (runs for all tools)
|
|
183
|
-
agent.toolbox.before { |args, tool_name:| puts "Calling #{tool_name}" }
|
|
184
|
-
end
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
### Plugin API
|
|
188
|
-
|
|
189
|
-
Plugins receive an `agent` object with access to:
|
|
190
|
-
- `agent.toolbox` - add tools, register hooks
|
|
191
|
-
- `agent.terminal` - output to the user (`say`, `ask`, `markdown`)
|
|
192
|
-
- `agent.commands` - register slash commands
|
|
193
|
-
- `agent.conversation` - access message history
|
|
194
|
-
- `agent.client` - the LLM client
|
|
195
|
-
- `agent.fork(system_prompt:)` - create a sub-agent
|
|
196
|
-
|
|
197
|
-
## MCP Configuration
|
|
198
|
-
|
|
199
|
-
Configure MCP servers in `~/.elelem/mcp.json` or `.elelem/mcp.json`:
|
|
200
|
-
|
|
201
|
-
```json
|
|
202
|
-
{
|
|
203
|
-
"mcpServers": {
|
|
204
|
-
"gitlab": {
|
|
205
|
-
"command": "npx",
|
|
206
|
-
"args": ["-y", "@anthropics/gitlab-mcp"],
|
|
207
|
-
"env": {
|
|
208
|
-
"GITLAB_TOKEN": "${GITLAB_TOKEN}"
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
"remote": {
|
|
212
|
-
"type": "http",
|
|
213
|
-
"url": "https://mcp.example.com/sse"
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
39
|
+
$ git clone https://git.mokhan.ca/elelem/elelem.git
|
|
217
40
|
```
|
|
218
41
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
## Known Limitations
|
|
222
|
-
|
|
223
|
-
* Assumes the current directory is a Git repository.
|
|
224
|
-
* No sandboxing – the LLM can run arbitrary commands.
|
|
225
|
-
* Error handling is minimal; exceptions are returned as an `error` field.
|
|
226
|
-
|
|
227
|
-
## Contributing
|
|
228
|
-
|
|
229
|
-
Send me an email. For instructions see https://git-send-email.io/.
|
|
42
|
+
Send me a patch via email. For instructions see https://git-send-email.io/.
|
|
230
43
|
|
|
231
44
|
## License
|
|
232
45
|
|
data/exe/elelem
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env -S ruby -W0
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "elelem"
|
|
4
|
+
require "elelem/cli"
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require "elelem/builtins"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
warn "elelem: gem install elelem-builtins for the default tools and commands"
|
|
10
|
+
end
|
|
5
11
|
|
|
6
12
|
Signal.trap("INT") { exit 1 }
|
|
7
13
|
|
data/lib/elelem/agent.rb
CHANGED
|
@@ -2,94 +2,100 @@
|
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class Agent
|
|
5
|
-
attr_reader :conversation, :
|
|
6
|
-
|
|
5
|
+
attr_reader :conversation, :toolbox, :output
|
|
6
|
+
attr_accessor :provider
|
|
7
7
|
|
|
8
|
-
def initialize(
|
|
9
|
-
@
|
|
8
|
+
def initialize(provider, toolbox: Toolbox.new, output: NullOutput.new, system_prompt: nil)
|
|
9
|
+
@provider = provider
|
|
10
10
|
@toolbox = toolbox
|
|
11
|
-
@
|
|
12
|
-
@
|
|
13
|
-
@conversation = Conversation.new
|
|
14
|
-
@system_prompt = system_prompt
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def repl
|
|
18
|
-
terminal.say "elelem v#{VERSION}"
|
|
19
|
-
loop do
|
|
20
|
-
input = terminal.ask("> ")
|
|
21
|
-
break if input.nil?
|
|
22
|
-
next if input.empty?
|
|
23
|
-
input.start_with?("/") ? command(input) : turn(input)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def command(input)
|
|
28
|
-
parts = input.delete_prefix("/").split(" ", 2)
|
|
29
|
-
name, args = parts[0], parts[1]
|
|
30
|
-
commands.run(name, args) || terminal.say(commands.names.join(" "))
|
|
11
|
+
@output = output
|
|
12
|
+
@conversation = Transcript.new(system_prompt: system_prompt)
|
|
31
13
|
end
|
|
32
14
|
|
|
33
15
|
def context
|
|
34
|
-
@conversation.to_a
|
|
16
|
+
@conversation.to_a
|
|
35
17
|
end
|
|
36
18
|
|
|
37
|
-
def
|
|
38
|
-
|
|
19
|
+
def turn(prompt)
|
|
20
|
+
@conversation.add(role: "user", content: prompt)
|
|
21
|
+
content = run_until_done([])
|
|
22
|
+
@conversation.add(role: "assistant", content: content)
|
|
23
|
+
content
|
|
39
24
|
end
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
@conversation.add(role: "user", content: input)
|
|
43
|
-
ctx = []
|
|
44
|
-
content = nil
|
|
26
|
+
private
|
|
45
27
|
|
|
28
|
+
def run_until_done(ctx)
|
|
46
29
|
loop do
|
|
47
|
-
|
|
48
|
-
content
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ctx << { role: "assistant", content: content, tool_calls: tool_calls }.compact
|
|
53
|
-
tool_calls.each do |tool_call|
|
|
54
|
-
ctx << { role: "tool", tool_call_id: tool_call[:id], content: process(tool_call).to_json }
|
|
55
|
-
end
|
|
30
|
+
content, tool_calls = fetch_and_say(ctx)
|
|
31
|
+
return content if tool_calls.empty?
|
|
32
|
+
|
|
33
|
+
ctx.concat(tool_turn(content, tool_calls))
|
|
56
34
|
end
|
|
35
|
+
end
|
|
57
36
|
|
|
58
|
-
|
|
59
|
-
|
|
37
|
+
def fetch_and_say(ctx)
|
|
38
|
+
output.waiting
|
|
39
|
+
content, tool_calls = fetch_response(ctx)
|
|
40
|
+
output.say(content, as: :markdown)
|
|
41
|
+
[content, tool_calls]
|
|
60
42
|
end
|
|
61
43
|
|
|
62
|
-
|
|
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
|
|
63
52
|
|
|
64
53
|
def process(tool_call)
|
|
65
|
-
name
|
|
66
|
-
|
|
67
|
-
toolbox.
|
|
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
|
|
68
64
|
end
|
|
69
65
|
|
|
70
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)
|
|
71
73
|
content = String.new
|
|
72
74
|
tool_calls = []
|
|
75
|
+
provider.fetch(messages(ctx), toolbox.to_a) { |event| handle_event(event, content, tool_calls) }
|
|
76
|
+
[content, tool_calls]
|
|
77
|
+
end
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
content << event[:text].to_s
|
|
78
|
-
when "thinking"
|
|
79
|
-
terminal.print(terminal.think(event[:text]))
|
|
80
|
-
when "tool_call"
|
|
81
|
-
tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
|
|
82
|
-
end
|
|
83
|
-
end
|
|
79
|
+
def messages(ctx)
|
|
80
|
+
@conversation.to_a(ctx)
|
|
81
|
+
end
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
def fetch_error(error)
|
|
84
|
+
warn_and_say(error)
|
|
85
|
+
["Error: #{error.message}", []]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def handle_event(event, content, tool_calls)
|
|
89
|
+
case event[:type]
|
|
90
|
+
when "saying" then content << event[:text].to_s
|
|
91
|
+
when "thinking" then output.thinking(event[:text])
|
|
92
|
+
when "doing" then tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
|
|
93
|
+
end
|
|
89
94
|
end
|
|
90
95
|
|
|
91
|
-
def
|
|
92
|
-
|
|
96
|
+
def warn_and_say(error)
|
|
97
|
+
Elelem.logger.warn("agent: #{error.message}")
|
|
98
|
+
output.say(error.message, as: :error)
|
|
93
99
|
end
|
|
94
100
|
end
|
|
95
101
|
end
|
data/lib/elelem/cli.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "elelem"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "reline"
|
|
7
|
+
|
|
8
|
+
module Elelem
|
|
9
|
+
class CLI
|
|
10
|
+
COMMANDS = %w[chat ask files help].freeze
|
|
11
|
+
USAGE = <<~TEXT
|
|
12
|
+
\nCommands:
|
|
13
|
+
chat Interactive REPL (default)
|
|
14
|
+
ask <prompt> One-shot query (reads stdin if piped)
|
|
15
|
+
files Output files as XML (no options)
|
|
16
|
+
help Show this help
|
|
17
|
+
\nOptions:
|
|
18
|
+
TEXT
|
|
19
|
+
private_constant :USAGE
|
|
20
|
+
|
|
21
|
+
def initialize(args, provider: "stub")
|
|
22
|
+
@provider = provider
|
|
23
|
+
@args = parse(args)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
command = @args.shift || "chat"
|
|
28
|
+
abort "Unknown command: #{command}" unless COMMANDS.include?(command)
|
|
29
|
+
send(command)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def parse(args)
|
|
35
|
+
@parser = OptionParser.new { |o| describe_options(o) }
|
|
36
|
+
@parser.parse!(args)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def describe_options(parser)
|
|
40
|
+
describe_usage(parser)
|
|
41
|
+
parser.on("-p", "--provider NAME", "Provider to use (default: #{@provider})") { |p| @provider = p }
|
|
42
|
+
parser.on("-h", "--help", "Show this help") { print_help_and_exit(parser) }
|
|
43
|
+
end
|
|
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
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def help
|
|
56
|
+
puts @parser
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def chat
|
|
60
|
+
Elelem.start(provider: @provider)
|
|
61
|
+
rescue StandardError, ScriptError => e
|
|
62
|
+
warn_and_abort(e)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ask
|
|
66
|
+
abort "Usage: elelem-chat ask <prompt>" if @args.empty?
|
|
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
|
|
73
|
+
|
|
74
|
+
def piped?
|
|
75
|
+
!$stdout.tty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def output_for_ask
|
|
79
|
+
Elelem::Output.new(stream: piped? ? $stderr : $stdout)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def prompt_for_ask
|
|
83
|
+
prompt = @args.join(" ")
|
|
84
|
+
$stdin.stat.pipe? ? "#{prompt}\n\n```\n#{$stdin.read}\n```" : prompt
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def print_reply(reply, piped:)
|
|
88
|
+
abort "elelem: no reply" if reply.to_s.strip.empty?
|
|
89
|
+
Elelem::Output.new.say(reply, as: :markdown) if piped
|
|
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>)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/elelem/commands.rb
CHANGED
|
@@ -2,19 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class Commands
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
def initialize(registry = {})
|
|
8
|
+
@registry = registry
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def clear!
|
|
12
|
+
@registry.clear
|
|
7
13
|
end
|
|
8
14
|
|
|
9
|
-
def register(name, description: "", &handler)
|
|
10
|
-
@registry[name] =
|
|
15
|
+
def register(name, description: "", completions: nil, &handler)
|
|
16
|
+
@registry[name] = SlashCommand.new(name, description, completions, &handler)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def completions_for(name, partial = "")
|
|
20
|
+
cmd = command_for(name)
|
|
21
|
+
return [] unless cmd&.completions
|
|
22
|
+
|
|
23
|
+
options = cmd.completions.respond_to?(:call) ? cmd.completions.call : cmd.completions
|
|
24
|
+
options.select { |o| o.start_with?(partial) }
|
|
11
25
|
end
|
|
12
26
|
|
|
13
27
|
def run(name, args = nil)
|
|
14
|
-
|
|
15
|
-
return false unless
|
|
28
|
+
command = command_for(name)
|
|
29
|
+
return false unless command
|
|
16
30
|
|
|
17
|
-
|
|
31
|
+
command.call(args)
|
|
18
32
|
true
|
|
19
33
|
end
|
|
20
34
|
|
|
@@ -23,11 +37,13 @@ module Elelem
|
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def each
|
|
26
|
-
@registry.each { |name,
|
|
40
|
+
@registry.each { |name, command| yield "/#{name}", command.description }
|
|
27
41
|
end
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def command_for(name)
|
|
46
|
+
@registry[name]
|
|
31
47
|
end
|
|
32
48
|
end
|
|
33
49
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
module Config
|
|
5
|
+
extend SingleForwardable
|
|
6
|
+
|
|
7
|
+
def_single_delegators :default, :build_provider, :build_session, :apply, :reload, :names
|
|
8
|
+
|
|
9
|
+
def self.default
|
|
10
|
+
@default ||= Registry.new
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.configure(&)
|
|
15
|
+
Config.default.configure(&)
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/elelem/conversation.rb
CHANGED
data/lib/elelem/input.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
|
|
5
|
+
module Elelem
|
|
6
|
+
class Input
|
|
7
|
+
def initialize(commands: [])
|
|
8
|
+
@commands = commands
|
|
9
|
+
Reline.autocompletion = true
|
|
10
|
+
Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ask(prompt)
|
|
14
|
+
Reline.readline(prompt, true)&.strip
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def interactive?
|
|
18
|
+
$stdin.tty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def complete(target, preposing)
|
|
24
|
+
line = "#{preposing}#{target}"
|
|
25
|
+
return command_names.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
|
|
26
|
+
return complete_command_args_for(preposing, target) if preposing.start_with?("/") && preposing.include?(" ")
|
|
27
|
+
|
|
28
|
+
complete_files(target)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def complete_command_args_for(preposing, target)
|
|
32
|
+
cmd_name = preposing.delete_prefix("/").split(" ", 2).first
|
|
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)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def complete_files(target)
|
|
47
|
+
output, = Open3.capture2("bash", "-c", "compgen -f #{Shellwords.escape(target)}")
|
|
48
|
+
output.lines.map(&:strip).first(20)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|