elelem 0.9.1 → 0.10.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +93 -16
  4. data/Rakefile +0 -11
  5. data/exe/elelem +2 -79
  6. data/lib/elelem/agent.rb +33 -124
  7. data/lib/elelem/commands.rb +33 -0
  8. data/lib/elelem/conversation.rb +25 -0
  9. data/lib/elelem/mcp/oauth.rb +217 -0
  10. data/lib/elelem/mcp/token_storage.rb +60 -0
  11. data/lib/elelem/mcp.rb +164 -17
  12. data/lib/elelem/net/claude.rb +6 -4
  13. data/lib/elelem/net/ollama.rb +5 -2
  14. data/lib/elelem/net/openai.rb +6 -4
  15. data/lib/elelem/net.rb +0 -3
  16. data/lib/elelem/permissions.rb +45 -0
  17. data/lib/elelem/plugins/builtins.rb +96 -0
  18. data/lib/elelem/plugins/edit.rb +3 -3
  19. data/lib/elelem/plugins/eval.rb +4 -4
  20. data/lib/elelem/plugins/execute.rb +5 -5
  21. data/lib/elelem/plugins/git.rb +20 -0
  22. data/lib/elelem/plugins/glob.rb +13 -0
  23. data/lib/elelem/plugins/grep.rb +21 -0
  24. data/lib/elelem/plugins/list.rb +14 -0
  25. data/lib/elelem/plugins/mcp.rb +14 -8
  26. data/lib/elelem/plugins/permissions.json +6 -0
  27. data/lib/elelem/plugins/read.rb +6 -6
  28. data/lib/elelem/plugins/task.rb +14 -0
  29. data/lib/elelem/plugins/tools.rb +13 -0
  30. data/lib/elelem/plugins/verify.rb +4 -4
  31. data/lib/elelem/plugins/write.rb +17 -6
  32. data/lib/elelem/plugins/zz_confirm.rb +9 -0
  33. data/lib/elelem/plugins.rb +6 -6
  34. data/lib/elelem/system_prompt.rb +123 -29
  35. data/lib/elelem/terminal.rb +7 -1
  36. data/lib/elelem/tool.rb +6 -15
  37. data/lib/elelem/toolbox.rb +13 -4
  38. data/lib/elelem/version.rb +1 -1
  39. data/lib/elelem.rb +96 -5
  40. metadata +99 -3
  41. data/lib/elelem/plugins/confirm.rb +0 -12
  42. data/lib/elelem/templates/system_prompt.erb +0 -53
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab9d6c213098f0a206e17684325c3f756ee1456ec05040eb8b2e96b46c9e5ee5
4
- data.tar.gz: a6db8b35f17c43558a799198785fef34e062c861a73b000eb148531fe6a1f2dc
3
+ metadata.gz: 3e95bbe823f4cf39a8a801bd32240caa47dcc3bdea6ce803f7c744f5f192f74c
4
+ data.tar.gz: 685b6b52ff14408979bd61a0ea42e87d24602f398392ac845fcc893eceabccae
5
5
  SHA512:
6
- metadata.gz: eb916323872221ee6b8a6233a078f59183f91c082fc926144e1d91fe36176bc34645cc613b30372ac89e5668697dcf99ad36624cf7aa80d17076a911e7f6d78a
7
- data.tar.gz: e7d619049618bc475c1f143d9e6fe46df2a58a5288fb916da790ae1ecde2cd6c0f992f04e95c2f365faaa17424d20ed8cc214d10a1f4b9081d54c13481966331
6
+ metadata.gz: cc605e03c30962a7f75d0846cde1bfb46d41c73ffbd07ee49f933fe51076ae08df7f9fb86b933d16864fd5aad7de9a29297d3b7da01337a9aa62d80aa9fa5940
7
+ data.tar.gz: 5d2580db0a5e607c360bb1f691d2eabaec7a37af7ca4f705950fed4048a567be46077c04e5a289163473f679ec95b76b8b04e5c56d84a3bb4eb42be6cc1b4da6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,37 @@
1
+ ## [0.10.0] - 2026-01-27
2
+
3
+ ### Added
4
+ - **Async MCP loading** for faster startup - tools load in background thread
5
+ - **HTTP MCP servers** with SSE support and session management
6
+ - **OAuth authentication** for MCP servers with PKCE, automatic token refresh
7
+ - **Global hooks** - `toolbox.before`/`toolbox.after` without tool name applies to all tools
8
+ - **`/context` improvements**: `/context <n>` to view entry, `/context json` for full dump
9
+ - **ast-grep (`sg`) support** for building repo maps - faster and more accurate than ctags
10
+ - **New tools**: `glob`, `grep`, `list`, `git`, `task`, `/tools` command
11
+ - **Permissions system** (`lib/elelem/permissions.rb`) for tool access control
12
+ - **OpenAI reasoning mode** - enables `Reasoning: high` for o-series models
13
+ - **Test coverage** for OAuth, token storage, HTTP MCP, SSE parsing, global hooks
14
+
15
+ ### Changed
16
+ - **BREAKING: Plugin API** - plugins now receive `agent` instead of `toolbox`
17
+ - Old: `Elelem::Plugins.register(:name) { |toolbox| toolbox.add(...) }`
18
+ - New: `Elelem::Plugins.register(:name) { |agent| agent.toolbox.add(...) }`
19
+ - Plugins can now access `agent.terminal`, `agent.commands`, `agent.conversation`
20
+ - Extracted `Conversation` class from `Agent` for better separation of concerns
21
+ - Extracted `Commands` class for slash command handling
22
+ - Refactored LLM fetch interface to emit separate events for thinking/content/tool_calls
23
+ - Simplified system prompt with inline ERB template
24
+ - Renamed confirm plugin to `zz_confirm` to ensure it loads last
25
+ - MCP logs now write to `~/.elelem/mcp.log` instead of working directory
26
+ - Tool schema now frozen to prevent mutation
27
+ - Uses `Open3.capture2` instead of backticks for thread safety
28
+ - Improved ANSI escape sequence stripping in `/shell` transcripts
29
+
30
+ ## [0.9.2] - 2026-01-22
31
+
32
+ ### Fixed
33
+ - Suppress pathname gem constant redefinition warnings on Ruby 4.0
34
+
1
35
  ## [0.9.1] - 2026-01-22
2
36
 
3
37
  ### Fixed
data/README.md CHANGED
@@ -31,17 +31,18 @@ Elelem relies on several external tools. Install the ones you need:
31
31
 
32
32
  | Tool | Purpose | Install |
33
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` |
34
+ | [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
36
35
  | [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
36
  | [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` |
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` |
41
42
 
42
43
  **Required:** Git, Ollama (or another LLM provider)
43
44
 
44
- **Recommended:** glow, ctags, ripgrep, fd
45
+ **Recommended:** glow, jq, ctags, ripgrep, fd
45
46
 
46
47
  **Optional:** ast-grep (for structural code search)
47
48
 
@@ -128,18 +129,94 @@ Each provider reads its configuration from environment variables:
128
129
  * **Conversation History** – persists across turns; can be cleared.
129
130
  * **Context Dump** – `/context` shows the current conversation state.
130
131
 
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.
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
+ ```
135
186
 
136
- | Tool | Purpose | Parameters |
137
- | --------- | ------------------ | ------------------ |
138
- | `read` | Read file contents | `path` |
139
- | `write` | Write file | `path`, `content` |
140
- | `execute` | Run shell command | `command` |
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
+ }
217
+ ```
141
218
 
142
- Aliases: `bash`, `sh`, `exec` `execute`; `open` → `read`
219
+ HTTP servers support OAuth authentication automatically.
143
220
 
144
221
  ## Known Limitations
145
222
 
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,8 @@
1
- #!/usr/bin/env ruby
1
+ #!/usr/bin/env -S ruby -W0
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "elelem"
5
- require "optparse"
6
5
 
7
6
  Signal.trap("INT") { exit 1 }
8
7
 
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
83
- end
84
-
85
- App.new(ARGV).run
8
+ Elelem::CLI.new(ARGV).run
data/lib/elelem/agent.rb CHANGED
@@ -2,40 +2,16 @@
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, :client, :toolbox, :terminal, :commands
6
+ attr_writer :terminal, :toolbox, :commands
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)
8
+ def initialize(client, toolbox: Toolbox.new, terminal: nil, system_prompt: nil, commands: nil)
32
9
  @client = client
33
10
  @toolbox = toolbox
34
- @terminal = terminal || Terminal.new(commands: COMMANDS)
35
- @history = history || []
11
+ @commands = commands || Commands.new
12
+ @terminal = terminal
13
+ @conversation = Conversation.new
36
14
  @system_prompt = system_prompt
37
- @memory = nil
38
- register_task_tool
39
15
  end
40
16
 
41
17
  def repl
@@ -49,27 +25,21 @@ module Elelem
49
25
  end
50
26
 
51
27
  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
+ parts = input.delete_prefix("/").split(" ", 2)
29
+ name, args = parts[0], parts[1]
30
+ commands.run(name, args) || terminal.say(commands.names.join(" "))
31
+ end
32
+
33
+ def context
34
+ @conversation.to_a(system_prompt: system_prompt)
35
+ end
36
+
37
+ def fork(system_prompt:)
38
+ Agent.new(client, toolbox: toolbox, terminal: terminal, system_prompt: system_prompt)
68
39
  end
69
40
 
70
41
  def turn(input)
71
- compact_if_needed
72
- history << { role: "user", content: input }
42
+ @conversation.add(role: "user", content: input)
73
43
  ctx = []
74
44
  content = nil
75
45
 
@@ -85,7 +55,7 @@ module Elelem
85
55
  end
86
56
  end
87
57
 
88
- history << { role: "assistant", content: content }
58
+ @conversation.add(role: "assistant", content: content)
89
59
  content
90
60
  end
91
61
 
@@ -97,90 +67,29 @@ module Elelem
97
67
  toolbox.run(name.to_s, args)
98
68
  end
99
69
 
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
133
- end
134
-
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/, "")
142
- end
143
-
144
70
  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]
71
+ content = String.new
72
+ tool_calls = []
73
+
74
+ client.fetch(@conversation.to_a(system_prompt: system_prompt) + ctx, toolbox.to_a) do |event|
75
+ case event[:type]
76
+ when "saying"
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
149
83
  end
84
+
150
85
  [content, tool_calls]
151
86
  rescue => e
152
87
  terminal.say "\n ✗ #{e.message}"
153
88
  ["Error: #{e.message} #{e.backtrace.join("\n")}", []]
154
89
  end
155
90
 
156
- def combined_history
157
- [{ role: "system", content: system_prompt }] + history
158
- end
159
-
160
91
  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
92
+ @system_prompt || SystemPrompt.new.render
184
93
  end
185
94
  end
186
95
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Commands
5
+ def initialize
6
+ @registry = {}
7
+ end
8
+
9
+ def register(name, description: "", &handler)
10
+ @registry[name] = { description: description, handler: handler }
11
+ end
12
+
13
+ def run(name, args = nil)
14
+ entry = @registry[name]
15
+ return false unless entry
16
+
17
+ entry[:handler].arity == 0 ? entry[:handler].call : entry[:handler].call(args)
18
+ true
19
+ end
20
+
21
+ def names
22
+ @registry.keys.map { |name| "/#{name}" }
23
+ end
24
+
25
+ def each
26
+ @registry.each { |name, entry| yield "/#{name}", entry[:description] }
27
+ end
28
+
29
+ def include?(name)
30
+ @registry.key?(name)
31
+ end
32
+ end
33
+ 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