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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -195
  3. data/exe/elelem +7 -1
  4. data/lib/elelem/agent.rb +68 -62
  5. data/lib/elelem/cli.rb +116 -0
  6. data/lib/elelem/commands.rb +26 -10
  7. data/lib/elelem/config.rb +17 -0
  8. data/lib/elelem/conversation.rb +1 -0
  9. data/lib/elelem/input.rb +51 -0
  10. data/lib/elelem/null_input.rb +8 -0
  11. data/lib/elelem/null_output.rb +7 -0
  12. data/lib/elelem/null_tool.rb +18 -0
  13. data/lib/elelem/output.rb +55 -0
  14. data/lib/elelem/plugins.rb +20 -27
  15. data/lib/elelem/prompts/default.erb +3 -0
  16. data/lib/elelem/registration.rb +37 -0
  17. data/lib/elelem/registry.rb +68 -0
  18. data/lib/elelem/repl.rb +34 -0
  19. data/lib/elelem/result.rb +27 -0
  20. data/lib/elelem/schema.rb +20 -0
  21. data/lib/elelem/session.rb +17 -0
  22. data/lib/elelem/slash_command.rb +18 -0
  23. data/lib/elelem/stub_provider.rb +15 -0
  24. data/lib/elelem/system_prompt.rb +12 -135
  25. data/lib/elelem/tool.rb +7 -12
  26. data/lib/elelem/toolbox.rb +40 -31
  27. data/lib/elelem/transcript.rb +26 -0
  28. data/lib/elelem/version.rb +2 -2
  29. data/lib/elelem.rb +28 -113
  30. metadata +48 -155
  31. data/CHANGELOG.md +0 -309
  32. data/lib/elelem/mcp/oauth.rb +0 -217
  33. data/lib/elelem/mcp/token_storage.rb +0 -60
  34. data/lib/elelem/mcp.rb +0 -243
  35. data/lib/elelem/net/claude.rb +0 -202
  36. data/lib/elelem/net/ollama.rb +0 -81
  37. data/lib/elelem/net/openai.rb +0 -88
  38. data/lib/elelem/net.rb +0 -13
  39. data/lib/elelem/permissions.rb +0 -45
  40. data/lib/elelem/plugins/builtins.rb +0 -96
  41. data/lib/elelem/plugins/edit.rb +0 -15
  42. data/lib/elelem/plugins/eval.rb +0 -20
  43. data/lib/elelem/plugins/execute.rb +0 -18
  44. data/lib/elelem/plugins/git.rb +0 -20
  45. data/lib/elelem/plugins/glob.rb +0 -13
  46. data/lib/elelem/plugins/grep.rb +0 -21
  47. data/lib/elelem/plugins/list.rb +0 -14
  48. data/lib/elelem/plugins/mcp.rb +0 -20
  49. data/lib/elelem/plugins/permissions.json +0 -6
  50. data/lib/elelem/plugins/read.rb +0 -21
  51. data/lib/elelem/plugins/task.rb +0 -14
  52. data/lib/elelem/plugins/tools.rb +0 -13
  53. data/lib/elelem/plugins/verify.rb +0 -47
  54. data/lib/elelem/plugins/write.rb +0 -34
  55. data/lib/elelem/plugins/zz_confirm.rb +0 -9
  56. data/lib/elelem/terminal.rb +0 -103
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:builtins) do |agent|
4
- agent.commands.register("exit", description: "Exit elelem") { exit(0) }
5
-
6
- agent.commands.register("clear", description: "Clear conversation history") do
7
- agent.conversation.clear!
8
- agent.terminal.say " → context cleared"
9
- end
10
-
11
- agent.commands.register("context", description: "Show conversation context") do |args|
12
- messages = agent.context
13
-
14
- case args
15
- when nil, ""
16
- messages.each_with_index do |msg, i|
17
- role = msg[:role]
18
- preview = msg[:content].to_s.lines.first&.strip&.slice(0, 60) || ""
19
- preview += "..." if msg[:content].to_s.length > 60
20
- agent.terminal.say " #{i + 1}. #{role}: #{preview}"
21
- end
22
- when "json"
23
- agent.terminal.say JSON.pretty_generate(messages)
24
- when /^\d+$/
25
- index = args.to_i - 1
26
- if index >= 0 && index < messages.length
27
- content = messages[index][:content].to_s
28
- agent.terminal.say(agent.terminal.markdown(content))
29
- else
30
- agent.terminal.say " Invalid index: #{args}"
31
- end
32
- else
33
- agent.terminal.say " Usage: /context [json|<number>]"
34
- end
35
- end
36
-
37
- strip_ansi = ->(text) do
38
- text
39
- .gsub(/^Script started.*?\n/, "")
40
- .gsub(/\nScript done.*$/, "")
41
- .gsub(/\e\].*?(?:\a|\e\\)/, "")
42
- .gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
43
- .gsub(/\e[PX^_].*?\e\\/, "")
44
- .gsub(/\e./, "")
45
- .gsub(/[\b]/, "")
46
- .gsub(/\r/, "")
47
- end
48
-
49
- agent.commands.register("shell", description: "Start interactive shell") do
50
- transcript = Tempfile.create do |file|
51
- system("script", "-q", file.path, chdir: Dir.pwd)
52
- strip_ansi.call(File.read(file.path))
53
- end
54
- agent.conversation.add(role: "user", content: transcript) unless transcript.strip.empty?
55
- end
56
-
57
- agent.commands.register("init", description: "Generate AGENTS.md") do
58
- system_prompt = <<~PROMPT
59
- AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
60
-
61
- # AGENTS.md Spec (https://agents.md/)
62
- A file providing context and instructions for AI coding agents.
63
-
64
- ## Recommended Sections
65
- - Commands: build, test, lint commands
66
- - Code Style: conventions, patterns
67
- - Architecture: key components and flow
68
- - Testing: how to run tests
69
-
70
- ## Process
71
- 1. Read README.md if present
72
- 2. Identify language (Gemfile, package.json, go.mod)
73
- 3. Find test scripts (bin/test, npm test)
74
- 4. Check linter configs
75
- 5. Write concise AGENTS.md
76
-
77
- Keep it minimal. No fluff.
78
- PROMPT
79
-
80
- agent.fork(system_prompt: system_prompt).turn("Generate AGENTS.md for this project")
81
- end
82
-
83
- agent.commands.register("reload", description: "Reload plugins and source") do
84
- lib_dir = File.expand_path("../..", __dir__)
85
- original_verbose, $VERBOSE = $VERBOSE, nil
86
- Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
87
- $VERBOSE = original_verbose
88
- agent.toolbox = Elelem::Toolbox.new
89
- agent.commands = Elelem::Commands.new
90
- Elelem::Plugins.reload!(agent)
91
- end
92
-
93
- agent.commands.register("help", description: "Show available commands") do
94
- agent.terminal.say agent.commands.names.join(" ")
95
- end
96
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:edit) do |agent|
4
- agent.toolbox.add("edit",
5
- description: "Replace first occurrence of text in file",
6
- params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
7
- required: ["path", "old", "new"]
8
- ) do |a|
9
- path = Pathname.new(a["path"]).expand_path
10
- content = path.read
11
- agent.toolbox
12
- .run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
13
- .merge(replaced: a["old"], with: a["new"])
14
- end
15
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:eval) do |agent|
4
- description = <<~'DESC'
5
- Evaluate Ruby code. Available API:
6
-
7
- name = "search"
8
- agent.toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
9
- agent.toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
10
- end
11
- DESC
12
-
13
- agent.toolbox.add("eval",
14
- description: description,
15
- params: { ruby: { type: "string" } },
16
- required: ["ruby"]
17
- ) do |args|
18
- { result: binding.eval(args["ruby"]) }
19
- end
20
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:execute) do |agent|
4
- agent.toolbox.add("execute",
5
- description: "Run shell command (supports pipes and redirections)",
6
- params: { command: { type: "string" } },
7
- required: ["command"],
8
- aliases: ["bash", "sh", "exec", "execute<|channel|>"]
9
- ) do |a|
10
- Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
11
- end
12
-
13
- agent.toolbox.after("execute") do |args, result|
14
- next if result[:exit_status] == 0
15
-
16
- agent.terminal.say agent.toolbox.header("execute", args, state: "x")
17
- end
18
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:git) do |agent|
4
- allowed = %w[status diff log show branch checkout add reset stash].freeze
5
-
6
- agent.toolbox.add("git",
7
- description: "Run git command",
8
- params: { command: { type: "string" }, args: { type: "array" } },
9
- required: ["command"]
10
- ) do |a|
11
- cmd = a["command"]
12
- next { error: "not allowed: #{cmd}" } unless allowed.include?(cmd)
13
-
14
- agent.toolbox.exec("git", cmd, *(a["args"] || []))
15
- end
16
-
17
- agent.toolbox.after("git") do |_, result|
18
- agent.terminal.say " ! #{result[:error]}" if result[:error]
19
- end
20
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:glob) do |agent|
4
- agent.toolbox.add("glob",
5
- description: "Find files matching pattern",
6
- params: { pattern: { type: "string" }, path: { type: "string" } },
7
- required: ["pattern"]
8
- ) do |a|
9
- path = a["path"] || "."
10
- result = agent.toolbox.exec("fd", "--glob", a["pattern"], path)
11
- result[:ok] ? result : agent.toolbox.exec("find", path, "-name", a["pattern"])
12
- end
13
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:grep) do |agent|
4
- agent.toolbox.add("grep",
5
- description: "Search file contents",
6
- params: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" } },
7
- required: ["pattern"]
8
- ) do |a|
9
- path = a["path"] || "."
10
- glob = a["glob"]
11
- rg_args = ["rg", "-n", a["pattern"], path]
12
- rg_args += ["-g", glob] if glob
13
- result = agent.toolbox.exec(*rg_args)
14
- next result if result[:ok]
15
-
16
- grep_args = ["grep", "-rn"]
17
- grep_args += ["--include", glob] if glob
18
- grep_args += [a["pattern"], path]
19
- agent.toolbox.exec(*grep_args)
20
- end
21
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:list) do |agent|
4
- agent.toolbox.add("list",
5
- description: "List directory contents",
6
- params: { path: { type: "string" }, recursive: { type: "boolean" } },
7
- required: [],
8
- aliases: ["ls"]
9
- ) do |a|
10
- path = a["path"] || "."
11
- flags = a["recursive"] ? "-laR" : "-la"
12
- agent.toolbox.exec("ls", flags, path)
13
- end
14
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:mcp) do |agent|
4
- mcp = Elelem::MCP.new
5
- at_exit { mcp.close }
6
-
7
- Thread.new do
8
- mcp.tools.each do |name, tool|
9
- agent.toolbox.add(
10
- name,
11
- description: tool[:description],
12
- params: tool[:params],
13
- required: tool[:required],
14
- &tool[:fn]
15
- )
16
- end
17
- rescue => e
18
- warn "MCP failed: #{e.message}"
19
- end
20
- end
@@ -1,6 +0,0 @@
1
- {
2
- "read": "allow",
3
- "write": "ask",
4
- "edit": "ask",
5
- "execute": "ask"
6
- }
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:read) do |agent|
4
- agent.toolbox.add("read",
5
- description: "Read file",
6
- params: { path: { type: "string" } },
7
- required: ["path"],
8
- aliases: ["open"]
9
- ) do |a|
10
- path = Pathname.new(a["path"]).expand_path
11
- path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
12
- end
13
-
14
- agent.toolbox.after("read") do |_, result|
15
- if result[:error]
16
- agent.terminal.say " ! #{result[:error]}"
17
- else
18
- agent.terminal.display_file(result[:path], fallback: result[:content])
19
- end
20
- end
21
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:task) do |agent|
4
- agent.toolbox.add("task",
5
- description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
6
- params: { prompt: { type: "string" } },
7
- required: ["prompt"]
8
- ) do |a|
9
- sub = Elelem::Agent.new(agent.client, toolbox: agent.toolbox, terminal: agent.terminal,
10
- system_prompt: "Research agent. Search, analyze, report. Be concise.")
11
- sub.turn(a["prompt"])
12
- { result: sub.conversation.last[:content] }
13
- end
14
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:tools) do |agent|
4
- agent.commands.register("tools", description: "List available tools") do
5
- agent.toolbox.tools.each_value do |tool|
6
- agent.terminal.say ""
7
- agent.terminal.say " #{tool.name}"
8
- agent.terminal.say " #{tool.description}"
9
- tool.params.each { |k, v| agent.terminal.say " #{k}: #{v[:type] || v["type"]}" }
10
- agent.terminal.say " aliases: #{tool.aliases.join(", ")}" if tool.aliases.any?
11
- end
12
- end
13
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- module Verifiers
5
- SYNTAX = {
6
- ".rb" => "ruby -c %{path}",
7
- ".erb" => "erb -x %{path} | ruby -c",
8
- ".py" => "python -m py_compile %{path}",
9
- ".go" => "go vet %{path}",
10
- ".rs" => "cargo check --quiet",
11
- ".ts" => "npx tsc --noEmit %{path}",
12
- ".js" => "node --check %{path}",
13
- }.freeze
14
-
15
- def self.for(path)
16
- return [] unless path
17
-
18
- cmds = []
19
- ext = File.extname(path)
20
- cmds << (SYNTAX[ext] % { path: path }) if SYNTAX[ext]
21
- cmds << test_runner
22
- cmds.compact
23
- end
24
-
25
- def self.test_runner
26
- %w[bin/test script/test].find { |s| File.executable?(s) }
27
- end
28
- end
29
-
30
- Plugins.register(:verify) do |agent|
31
- agent.toolbox.add("verify",
32
- description: "Verify file syntax and run tests",
33
- params: { path: { type: "string" } },
34
- required: ["path"]
35
- ) do |a|
36
- path = a["path"]
37
- Verifiers.for(path).inject({verified: []}) do |memo, cmd|
38
- agent.terminal.say agent.toolbox.header("execute", { "command" => cmd })
39
- v = agent.toolbox.run("execute", { "command" => cmd })
40
- break v.merge(path: path, command: cmd) if v[:exit_status] != 0
41
-
42
- memo[:verified] << cmd
43
- memo
44
- end
45
- end
46
- end
47
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:write) do |agent|
4
- agent.toolbox.add("write",
5
- description: "Write file",
6
- params: { path: { type: "string" }, content: { type: "string" } },
7
- required: ["path", "content"],
8
- aliases: ["write<|channel|>"]
9
- ) do |a|
10
- path = Pathname.new(a["path"]).expand_path
11
- FileUtils.mkdir_p(path.dirname)
12
- { bytes: path.write(a["content"]), path: a["path"] }
13
- end
14
-
15
- agent.toolbox.before("write") do |args|
16
- path = Pathname.new(args["path"]).expand_path
17
- next unless path.exist? && $stdin.tty?
18
-
19
- Tempfile.create(["elelem", File.extname(path)]) do |t|
20
- t.write(args["content"])
21
- t.flush
22
- system("diff", "--color=always", "-u", path.to_s, t.path)
23
- end
24
- end
25
-
26
- agent.toolbox.after("write") do |_, result|
27
- if result[:error]
28
- agent.terminal.say " ! #{result[:error]}"
29
- else
30
- agent.terminal.display_file(result[:path], fallback: " -> #{result[:path]}")
31
- agent.toolbox.run("verify", { "path" => result[:path] })
32
- end
33
- end
34
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Elelem::Plugins.register(:confirm) do |agent|
4
- permissions = Elelem::Permissions.new
5
-
6
- agent.toolbox.before do |args, tool_name:|
7
- permissions.check(tool_name, args, terminal: agent.terminal)
8
- end
9
- end
@@ -1,103 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- class Terminal
5
- def initialize(commands: [], quiet: false)
6
- @commands = commands
7
- @quiet = quiet
8
- @dots_thread = nil
9
- setup_completion unless @quiet
10
- end
11
-
12
- def ask(prompt)
13
- Reline.readline(prompt, true)&.strip
14
- end
15
-
16
- def think(text)
17
- return if blank?(text)
18
-
19
- "\e[2;3m#{text}\e[0m"
20
- end
21
-
22
- def markdown(text)
23
- return if @quiet || blank?(text)
24
-
25
- newline(n: 2)
26
- width = $stdout.winsize[1] rescue 80
27
- IO.popen(["glow", "-s", "dark", "-w", width.to_s, "-"], "r+") do |io|
28
- io.write(text)
29
- io.close_write
30
- io.read
31
- end
32
- rescue Errno::ENOENT
33
- text
34
- end
35
-
36
- def print(text)
37
- return if @quiet || blank?(text)
38
-
39
- stop_dots
40
- $stdout.print text
41
- end
42
-
43
- def say(text)
44
- return if @quiet || blank?(text)
45
-
46
- stop_dots
47
- $stdout.puts text
48
- end
49
-
50
- def newline(n: 1)
51
- n.times { $stdout.puts("") }
52
- end
53
-
54
- def display_file(path, fallback: nil)
55
- return if @quiet
56
-
57
- system("bat", "--paging=never", path) || say(fallback || path)
58
- end
59
-
60
- def waiting
61
- return if @quiet
62
-
63
- @dots_thread = Thread.new do
64
- loop do
65
- $stdout.print "."
66
- $stdout.flush
67
- sleep 0.1
68
- end
69
- end
70
- end
71
-
72
- private
73
-
74
- def blank?(text)
75
- text.nil? || text.to_s.strip.empty?
76
- end
77
-
78
- def stop_dots
79
- return unless @dots_thread
80
-
81
- @dots_thread.kill
82
- @dots_thread = nil
83
- newline
84
- end
85
-
86
- def setup_completion
87
- Reline.autocompletion = true
88
- Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
89
- end
90
-
91
- def complete(target, preposing)
92
- line = "#{preposing}#{target}"
93
- return @commands.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
94
-
95
- complete_files(target)
96
- end
97
-
98
- def complete_files(target)
99
- result = Elelem.sh("bash", args: ["-c", "compgen -f #{target}"])
100
- result[:content].lines.map(&:strip).first(20)
101
- end
102
- end
103
- end