elelem 0.9.2 → 0.11.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -118
  3. data/Rakefile +0 -11
  4. data/exe/elelem +8 -79
  5. data/lib/elelem/agent.rb +51 -142
  6. data/lib/elelem/cli.rb +76 -0
  7. data/lib/elelem/commands.rb +49 -0
  8. data/lib/elelem/config.rb +17 -0
  9. data/lib/elelem/conversation.rb +25 -0
  10. data/lib/elelem/input.rb +53 -0
  11. data/lib/elelem/null_input.rb +8 -0
  12. data/lib/elelem/null_output.rb +7 -0
  13. data/lib/elelem/null_tool.rb +18 -0
  14. data/lib/elelem/output.rb +57 -0
  15. data/lib/elelem/plugins.rb +10 -21
  16. data/lib/elelem/prompts/default.erb +3 -0
  17. data/lib/elelem/registration.rb +37 -0
  18. data/lib/elelem/registry.rb +69 -0
  19. data/lib/elelem/result.rb +26 -0
  20. data/lib/elelem/slash_command.rb +18 -0
  21. data/lib/elelem/stub_provider.rb +15 -0
  22. data/lib/elelem/system_prompt.rb +4 -42
  23. data/lib/elelem/tool.rb +6 -14
  24. data/lib/elelem/toolbox.rb +23 -23
  25. data/lib/elelem/version.rb +2 -2
  26. data/lib/elelem.rb +29 -27
  27. metadata +49 -65
  28. data/CHANGELOG.md +0 -280
  29. data/lib/elelem/mcp.rb +0 -96
  30. data/lib/elelem/net/claude.rb +0 -200
  31. data/lib/elelem/net/ollama.rb +0 -78
  32. data/lib/elelem/net/openai.rb +0 -86
  33. data/lib/elelem/net.rb +0 -16
  34. data/lib/elelem/plugins/confirm.rb +0 -12
  35. data/lib/elelem/plugins/edit.rb +0 -15
  36. data/lib/elelem/plugins/eval.rb +0 -20
  37. data/lib/elelem/plugins/execute.rb +0 -18
  38. data/lib/elelem/plugins/mcp.rb +0 -14
  39. data/lib/elelem/plugins/read.rb +0 -21
  40. data/lib/elelem/plugins/verify.rb +0 -47
  41. data/lib/elelem/plugins/write.rb +0 -23
  42. data/lib/elelem/templates/system_prompt.erb +0 -53
  43. data/lib/elelem/terminal.rb +0 -97
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cacca0937e61d8f45af65cf8652677b547bb2b6b12122087befd3ff7a1babd5e
4
- data.tar.gz: 272d16dcfde24d82ce06d7c196969f548b969659bc0cbcf1d47cbbde900a450f
3
+ metadata.gz: 72a4cd636b39b6f30cc0520c3445a3b8748625fd5ca52f15b05d27349a0701b4
4
+ data.tar.gz: 12c55aaf15f42e7607fb60ed78500b44655ba6eaab857601ec58073378587866
5
5
  SHA512:
6
- metadata.gz: 39645d1abc07c1878922d514b7adc244b3fe52ff9c597fb0c1c8ad16d19c5b3eb24f2e5bbaebf79169103d5cf369daa6954e96f16ccbd438c68e33b3544b0a22
7
- data.tar.gz: 9983add61dcb73f7bf7e40c5ad13080a01b0e3391a4e7b163a166f534d87d7a5e973a62d304d1e15ecf25d27e5842fd402d9bdb959cf73bea2ebb8c256f3b419
6
+ metadata.gz: '018856c824b90cbbe658c3b117f683737f14c34f493ba3629281cd7318c11d039585b829e201a9a8bf591619747d2d6c7f1f3568f53a79c40222632a706303ac'
7
+ data.tar.gz: ab3132d4613f560dfb97e7bcfa406e5f49ac66f5e642ec85f77c1a25d030825f1a7d9f6401d54dd6eb3f767ec69506b8f114e3a269324fb5fd72f0ca97827a0d
data/README.md CHANGED
@@ -1,76 +1,17 @@
1
1
  # Elelem
2
2
 
3
- Fast, correct, autonomous pick two.
3
+ Fast, correct, autonomous - pick two.
4
4
 
5
5
  ## Purpose
6
6
 
7
- Elelem is a minimal coding agent written in Ruby. It is designed to help
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 simple, composable, minimal.
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
- | [Ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
35
- | [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
36
- | [ctags](https://ctags.io/) | Repo map generation | `brew install universal-ctags` / `apt install universal-ctags` |
37
- | [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
38
- | [fd](https://github.com/sharkdp/fd) | File discovery | `brew install fd` / `apt install fd-find` |
39
- | [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
40
- | [Git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
41
-
42
- **Required:** Git, Ollama (or another LLM provider)
43
-
44
- **Recommended:** glow, ctags, ripgrep, fd
45
-
46
- **Optional:** ast-grep (for structural code search)
47
-
48
- ## Scope
49
-
50
- Only plain-text and source-code files are supported. No binary handling,
51
- sandboxing, or permission checks are performed - the LLM has full access.
52
-
53
- ## Configuration
54
-
55
- Prefer convention over configuration. Add environment variables only after
56
- repeated use proves their usefulness.
57
-
58
- ## UI Expectations
59
-
60
- Keyboard-driven, minimal TUI. No mouse support or complex widgets.
61
-
62
- ## Coding Standards for the LLM
63
-
64
- * No extra error handling unless essential.
65
- * Keep methods short, single-purpose.
66
- * Descriptive, conventional names.
67
- * Use Ruby standard library where possible.
68
-
69
- ## Helpful Links
70
-
71
- * https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
72
- * https://www.anthropic.com/engineering/writing-tools-for-agents
73
- * https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
74
15
 
75
16
  ## Installation
76
17
 
@@ -88,68 +29,17 @@ Start an interactive chat session:
88
29
  elelem chat
89
30
  ```
90
31
 
91
- ### Options
32
+ ## Tools
92
33
 
93
- * `--provider` LLM provider: `ollama`, `anthropic`, `openai`, or `vertex-ai` (default: `ollama`).
94
- * `--model` – Override the default model for the selected provider.
34
+ Use the plugin API to add tools.
95
35
 
96
- ### Examples
36
+ ## Contributing
97
37
 
98
38
  ```bash
99
- # Default (Ollama)
100
- elelem chat
101
-
102
- # Anthropic Claude
103
- ANTHROPIC_API_KEY=sk-... elelem chat --provider anthropic
104
-
105
- # OpenAI
106
- OPENAI_API_KEY=sk-... elelem chat --provider openai
107
-
108
- # VertexAI (uses gcloud ADC)
109
- elelem chat --provider vertex-ai --model claude-sonnet-4@20250514
39
+ $ git clone https://git.mokhan.ca/elelem/elelem.git
110
40
  ```
111
41
 
112
- ### Provider Configuration
113
-
114
- Each provider reads its configuration from environment variables:
115
-
116
- | Provider | Environment Variables |
117
- |-------------|---------------------------------------------------|
118
- | ollama | `OLLAMA_HOST` (default: localhost:11434) |
119
- | anthropic | `ANTHROPIC_API_KEY` |
120
- | openai | `OPENAI_API_KEY`, `OPENAI_BASE_URL` |
121
- | vertex-ai | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION` |
122
-
123
- ## Features
124
-
125
- * **Interactive REPL** – clean, streaming chat.
126
- * **Toolbox** – file I/O and shell execution.
127
- * **Streaming Responses** – output appears in real time.
128
- * **Conversation History** – persists across turns; can be cleared.
129
- * **Context Dump** – `/context` shows the current conversation state.
130
-
131
- ## Toolbox Overview
132
-
133
- The `Toolbox` class is defined in `lib/elelem/toolbox.rb`. It supplies
134
- three tools, each represented by a JSON schema that the LLM can call.
135
-
136
- | Tool | Purpose | Parameters |
137
- | --------- | ------------------ | ------------------ |
138
- | `read` | Read file contents | `path` |
139
- | `write` | Write file | `path`, `content` |
140
- | `execute` | Run shell command | `command` |
141
-
142
- Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`
143
-
144
- ## Known Limitations
145
-
146
- * Assumes the current directory is a Git repository.
147
- * No sandboxing – the LLM can run arbitrary commands.
148
- * Error handling is minimal; exceptions are returned as an `error` field.
149
-
150
- ## Contributing
151
-
152
- 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/.
153
43
 
154
44
  ## License
155
45
 
data/Rakefile CHANGED
@@ -5,15 +5,4 @@ require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- task :files do
9
- IO.popen(%w[git ls-files], chdir: __dir__, err: IO::NULL) do |ls|
10
- ls.readlines.each do |f|
11
- next if f.start_with?(*%w[bin/ spec/ pkg/ .git .rspec Gemfile Rakefile])
12
- next if f.strip.end_with?(*%w[.toml .txt .md])
13
-
14
- puts f
15
- end
16
- end
17
- end
18
-
19
8
  task default: %i[spec]
data/exe/elelem CHANGED
@@ -1,85 +1,14 @@
1
1
  #!/usr/bin/env -S ruby -W0
2
2
  # frozen_string_literal: true
3
3
 
4
- require "elelem"
5
- require "optparse"
4
+ require "elelem/cli"
6
5
 
7
- Signal.trap("INT") { exit 1 }
8
-
9
- class App
10
- MODELS = {
11
- "ollama" => "gpt-oss:latest",
12
- "anthropic" => "claude-opus-4-5-20250514",
13
- "vertex" => "claude-opus-4-5@20251101",
14
- "openai" => "gpt-4o"
15
- }.freeze
16
-
17
- PROVIDERS = {
18
- "ollama" => ->(model) { Elelem::Net::Ollama.new(model: model, host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
19
- "anthropic" => ->(model) { Elelem::Net::Claude.anthropic(model: model, api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
20
- "vertex" => ->(model) { Elelem::Net::Claude.vertex(model: model, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
21
- "openai" => ->(model) { Elelem::Net::OpenAI.new(model: model, api_key: ENV.fetch("OPENAI_API_KEY")) }
22
- }.freeze
23
-
24
- def initialize(args)
25
- @provider = "ollama"
26
- @model = nil
27
- @args = parse(args)
28
- end
29
-
30
- def run
31
- command = @args.shift || "chat"
32
- send(command.tr("-", "_"))
33
- rescue NoMethodError
34
- abort "Unknown command: #{command}"
35
- end
36
-
37
- private
38
-
39
- def parse(args)
40
- @parser = OptionParser.new do |o|
41
- o.banner = "Usage: elelem [command] [options] [args]"
42
- o.separator "\nCommands:"
43
- o.separator " chat Interactive REPL (default)"
44
- o.separator " ask <prompt> One-shot query (reads stdin if piped)"
45
- o.separator " files Output files as XML (no options)"
46
- o.separator " help Show this help"
47
- o.separator "\nOptions:"
48
- o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
49
- o.on("-m", "--model NAME", "Override default model") { |m| @model = m }
50
- o.on("-h", "--help") { puts o; exit }
51
- end
52
- @parser.parse!(args)
53
- end
54
-
55
- def help
56
- puts @parser
57
- end
58
-
59
- def client
60
- model = @model || MODELS.fetch(@provider)
61
- PROVIDERS.fetch(@provider).call(model)
62
- end
63
-
64
- def chat = Elelem.start(client)
65
-
66
- def ask
67
- abort "Usage: elelem ask <prompt>" if @args.empty?
68
- prompt = @args.join(" ")
69
- prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
70
- Elelem::Terminal.new.markdown Elelem.ask(client, prompt)
71
- end
72
-
73
- def files
74
- files = $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
75
- puts "<documents>"
76
- files.each_with_index do |line, i|
77
- path = line.strip
78
- next if path.empty? || !File.file?(path)
79
- puts %Q{<document index="#{i + 1}"><source>#{path}</source><content><![CDATA[#{File.read(path)}]]></content></document>}
80
- end
81
- puts "</documents>"
82
- end
6
+ begin
7
+ require "elelem/builtins"
8
+ rescue LoadError
9
+ warn "elelem: gem install elelem-builtins for the default tools and commands"
83
10
  end
84
11
 
85
- App.new(ARGV).run
12
+ Signal.trap("INT") { exit 1 }
13
+
14
+ Elelem::CLI.new(ARGV).run
data/lib/elelem/agent.rb CHANGED
@@ -2,81 +2,42 @@
2
2
 
3
3
  module Elelem
4
4
  class Agent
5
- COMMANDS = %w[/clear /context /init /reload /shell /exit /help].freeze
6
- MAX_CONTEXT_MESSAGES = 50
7
- INIT_PROMPT = <<~PROMPT
8
- AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
5
+ attr_reader :conversation, :toolbox, :input, :output, :commands
6
+ attr_accessor :provider
9
7
 
10
- # AGENTS.md Spec (https://agents.md/)
11
- A file providing context and instructions for AI coding agents.
12
-
13
- ## Recommended Sections
14
- - Commands: build, test, lint commands
15
- - Code Style: conventions, patterns
16
- - Architecture: key components and flow
17
- - Testing: how to run tests
18
-
19
- ## Process
20
- 1. Read README.md if present
21
- 2. Identify language (Gemfile, package.json, go.mod)
22
- 3. Find test scripts (bin/test, npm test)
23
- 4. Check linter configs
24
- 5. Write concise AGENTS.md
25
-
26
- Keep it minimal. No fluff.
27
- PROMPT
28
-
29
- attr_reader :history, :client, :toolbox, :terminal
30
-
31
- def initialize(client, toolbox, terminal: nil, history: nil, system_prompt: nil)
32
- @client = client
8
+ def initialize(provider, toolbox: Toolbox.new, input: NullInput.new, output: NullOutput.new, system_prompt: nil, commands: nil)
9
+ @provider = provider
33
10
  @toolbox = toolbox
34
- @terminal = terminal || Terminal.new(commands: COMMANDS)
35
- @history = history || []
36
- @system_prompt = system_prompt
37
- @memory = nil
38
- register_task_tool
11
+ @commands = commands || Commands.new
12
+ @output = output
13
+ @input = input
14
+ @conversation = Conversation.new
15
+ @system_prompt = SystemPrompt.new(system_prompt)
39
16
  end
40
17
 
41
18
  def repl
42
- terminal.say "elelem v#{VERSION}"
19
+ output.say "elelem v#{VERSION}"
43
20
  loop do
44
- input = terminal.ask("> ")
45
- break if input.nil?
46
- next if input.empty?
47
- input.start_with?("/") ? command(input) : turn(input)
21
+ line = input.ask("> ")
22
+ break if line.nil?
23
+ next if line.empty?
24
+ line.start_with?("/") ? command(line) : turn(line)
48
25
  end
49
26
  end
50
27
 
51
- def command(input)
52
- case input
53
- when "/exit" then exit(0)
54
- when "/init" then init_agents_md
55
- when "/reload" then reload_source!
56
- when "/shell"
57
- transcript = start_shell
58
- history << { role: "user", content: transcript } unless transcript.strip.empty?
59
- when "/clear"
60
- @history = []
61
- @memory = nil
62
- terminal.say " → context cleared"
63
- when "/context"
64
- terminal.say JSON.pretty_generate(combined_history)
65
- else
66
- terminal.say COMMANDS.join(" ")
67
- end
28
+ def context
29
+ @conversation.to_a(system_prompt: @system_prompt.render)
68
30
  end
69
31
 
70
- def turn(input)
71
- compact_if_needed
72
- history << { role: "user", content: input }
32
+ def turn(prompt)
33
+ @conversation.add(role: "user", content: prompt)
73
34
  ctx = []
74
35
  content = nil
75
36
 
76
37
  loop do
77
- terminal.waiting
38
+ output.waiting
78
39
  content, tool_calls = fetch_response(ctx)
79
- terminal.say(terminal.markdown(content))
40
+ output.say(content, as: :markdown)
80
41
  break if tool_calls.empty?
81
42
 
82
43
  ctx << { role: "assistant", content: content, tool_calls: tool_calls }.compact
@@ -85,102 +46,50 @@ module Elelem
85
46
  end
86
47
  end
87
48
 
88
- history << { role: "assistant", content: content }
49
+ @conversation.add(role: "assistant", content: content)
89
50
  content
90
51
  end
91
52
 
92
53
  private
93
54
 
94
- def process(tool_call)
95
- name, args = tool_call[:name], tool_call[:arguments]
96
- terminal.say toolbox.header(name, args)
97
- toolbox.run(name.to_s, args)
98
- end
99
-
100
- def register_task_tool
101
- @toolbox.add("task",
102
- description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
103
- params: { prompt: { type: "string" } },
104
- required: ["prompt"]
105
- ) do |a|
106
- sub = Agent.new(client, toolbox, terminal: terminal,
107
- system_prompt: "Research agent. Search, analyze, report. Be concise.")
108
- sub.turn(a["prompt"])
109
- { result: sub.history.last[:content] }
110
- end
111
- end
112
-
113
- def init_agents_md
114
- sub = Agent.new(client, toolbox, terminal: terminal, system_prompt: INIT_PROMPT)
115
- sub.turn("Generate AGENTS.md for this project")
116
- end
117
-
118
- def reload_source!
119
- lib_dir = File.expand_path("..", __dir__)
120
- original_verbose, $VERBOSE = $VERBOSE, nil
121
- Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
122
- $VERBOSE = original_verbose
123
- @toolbox = Toolbox.new
124
- Plugins.reload!(@toolbox)
125
- register_task_tool
126
- end
127
-
128
- def start_shell
129
- Tempfile.create do |file|
130
- system("script", "-q", file.path, chdir: Dir.pwd)
131
- strip_ansi(File.read(file.path))
132
- end
55
+ def command(line)
56
+ parts = line.delete_prefix("/").split(" ", 2)
57
+ name, args = parts[0], parts[1]
58
+ commands.run(name, args) || output.say(commands.names.join(" "))
59
+ rescue => e
60
+ Elelem.logger.warn("agent: #{e.message}")
61
+ output.say(e.message, as: :error)
133
62
  end
134
63
 
135
- def strip_ansi(text)
136
- text.gsub(/^Script started.*?\n/, "")
137
- .gsub(/\nScript done.*$/, "")
138
- .gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
139
- .gsub(/\e\[\?[0-9]+[hl]/, "")
140
- .gsub(/[\b]/, "")
141
- .gsub(/\r/, "")
64
+ def process(tool_call)
65
+ name, args = tool_call[:name], tool_call[:arguments]
66
+ output.doing(toolbox.tool_for(name).name, args)
67
+ Elelem.logger.debug("agent: #{name}(#{args.inspect})")
68
+ result = toolbox.run(name.to_s, args)
69
+ Elelem.logger.debug("agent: #{name} -> #{result.inspect}")
70
+ result
142
71
  end
143
72
 
144
73
  def fetch_response(ctx)
145
- content = ""
146
- tool_calls = client.fetch(combined_history + ctx, toolbox.to_a) do |delta|
147
- content += delta[:content].to_s
148
- terminal.print(terminal.think(delta[:thinking])) if delta[:thinking]
74
+ content = String.new
75
+ tool_calls = []
76
+
77
+ provider.fetch(@conversation.to_a(system_prompt: @system_prompt.render) + ctx, toolbox.to_a) do |event|
78
+ case event[:type]
79
+ when "saying"
80
+ content << event[:text].to_s
81
+ when "thinking"
82
+ output.thinking(event[:text])
83
+ when "doing"
84
+ tool_calls << { id: event[:id], name: event[:name], arguments: event[:arguments] }
85
+ end
149
86
  end
87
+
150
88
  [content, tool_calls]
151
89
  rescue => e
152
- terminal.say "\n ✗ #{e.message}"
153
- ["Error: #{e.message} #{e.backtrace.join("\n")}", []]
154
- end
155
-
156
- def combined_history
157
- [{ role: "system", content: system_prompt }] + history
158
- end
159
-
160
- def system_prompt
161
- @system_prompt || SystemPrompt.new(memory: @memory).render
162
- end
163
-
164
- def compact_if_needed
165
- return if history.length <= MAX_CONTEXT_MESSAGES
166
-
167
- terminal.say " → compacting context"
168
- keep = MAX_CONTEXT_MESSAGES / 2
169
- old = history.first(history.length - keep)
170
-
171
- to_summarize = @memory ? [{ role: "memory", content: @memory }, *old] : old
172
- @memory = summarize(to_summarize)
173
- @history = history.last(keep)
174
- end
175
-
176
- def summarize(messages)
177
- text = messages.map { |message| { role: message[:role], content: message[:content] } }.to_json
178
-
179
- String.new.tap do |buffer|
180
- client.fetch([{ role: "user", content: "Summarize key facts:\n#{text}" }], []) do |d|
181
- buffer << d[:content].to_s
182
- end
183
- end
90
+ Elelem.logger.warn("agent: #{e.message}")
91
+ output.say(e.message, as: :error)
92
+ ["Error: #{e.message}", []]
184
93
  end
185
94
  end
186
95
  end
data/lib/elelem/cli.rb ADDED
@@ -0,0 +1,76 @@
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
+
12
+ def initialize(args, provider: "stub")
13
+ @provider = provider
14
+ @args = parse(args)
15
+ end
16
+
17
+ def run
18
+ command = @args.shift || "chat"
19
+ abort "Unknown command: #{command}" unless COMMANDS.include?(command)
20
+ send(command)
21
+ end
22
+
23
+ private
24
+
25
+ def parse(args)
26
+ @parser = OptionParser.new do |o|
27
+ o.banner = "Usage: #{File.basename($PROGRAM_NAME)} [command] [options] [args]"
28
+ o.separator "\nCommands:"
29
+ o.separator " chat Interactive REPL (default)"
30
+ o.separator " ask <prompt> One-shot query (reads stdin if piped)"
31
+ o.separator " files Output files as XML (no options)"
32
+ o.separator " help Show this help"
33
+ o.separator "\nOptions:"
34
+ o.on("-p", "--provider NAME", "Provider to use (default: #{@provider})") { |p| @provider = p }
35
+ o.on("-h", "--help", "Show this help") { puts o; exit }
36
+ end
37
+ @parser.parse!(args)
38
+ end
39
+
40
+ def help
41
+ puts @parser
42
+ end
43
+
44
+ def chat
45
+ Elelem.start(provider: @provider)
46
+ rescue => e
47
+ Elelem.logger.warn("cli: #{e.message}\n#{e.backtrace.join("\n")}")
48
+ abort "elelem: #{e.message}"
49
+ end
50
+
51
+ def ask
52
+ abort "Usage: elelem-chat ask <prompt>" if @args.empty?
53
+ prompt = @args.join(" ")
54
+ prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
55
+ piped = !$stdout.tty?
56
+ output = Elelem::Output.new(stream: piped ? $stderr : $stdout)
57
+ reply = Elelem.ask(prompt, provider: @provider, output: output)
58
+ abort "elelem: no reply" if reply.to_s.strip.empty?
59
+ Elelem::Output.new.say(reply, as: :markdown) if piped
60
+ rescue => e
61
+ Elelem.logger.warn("cli: #{e.message}\n#{e.backtrace.join("\n")}")
62
+ abort "elelem: #{e.message}"
63
+ end
64
+
65
+ def files
66
+ files = $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
67
+ puts "<documents>"
68
+ files.each_with_index do |line, i|
69
+ path = line.strip
70
+ next if path.empty? || !File.file?(path)
71
+ puts %Q{<document index="#{i + 1}"><source>#{path}</source><document_content><![CDATA[#{File.read(path)}]]></document_content></document>}
72
+ end
73
+ puts "</documents>"
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Commands
5
+ include Enumerable
6
+
7
+ def initialize(registry = {})
8
+ @registry = registry
9
+ end
10
+
11
+ def clear!
12
+ @registry.clear
13
+ end
14
+
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 && cmd.completions
22
+
23
+ options = cmd.completions.respond_to?(:call) ? cmd.completions.call : cmd.completions
24
+ options.select { |o| o.start_with?(partial) }
25
+ end
26
+
27
+ def run(name, args = nil)
28
+ command = command_for(name)
29
+ return false unless command
30
+
31
+ command.call(args)
32
+ true
33
+ end
34
+
35
+ def names
36
+ @registry.keys.map { |name| "/#{name}" }
37
+ end
38
+
39
+ def each
40
+ @registry.each { |name, command| yield "/#{name}", command.description }
41
+ end
42
+
43
+ private
44
+
45
+ def command_for(name)
46
+ @registry[name]
47
+ end
48
+ end
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_agent, :apply, :reload, :names
8
+
9
+ def self.default
10
+ @default ||= Registry.new
11
+ end
12
+ end
13
+
14
+ def self.configure(&block)
15
+ Config.default.configure(&block)
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Conversation
5
+ ROLES = %w[user assistant tool].freeze
6
+
7
+ def initialize
8
+ @messages = []
9
+ end
10
+
11
+ def add(role:, content:, **extra)
12
+ raise ArgumentError, "invalid role: #{role}" unless ROLES.include?(role)
13
+ @messages << { role: role, content: content, **extra }.compact
14
+ end
15
+
16
+ def last = @messages.last
17
+ def length = @messages.length
18
+ def clear! = @messages.clear
19
+
20
+ def to_a(system_prompt: nil)
21
+ base = system_prompt ? [{ role: "system", content: system_prompt }] : []
22
+ base + @messages
23
+ end
24
+ end
25
+ end