Commit e99a023

mo khan <mo@mokhan.ca>
2026-01-26 21:57:48
refactor: extract Conversation class from Agent
- Add Conversation class for message storage with role validation - Add Commands class for slash command registry - Move inline commands to plugins/builtins.rb - Move task tool to plugins/task.rb - Remove context compaction and memory (users can /clear) - Simplify SystemPrompt (no memory parameter) Agent is now focused on the REPL loop and LLM interaction.
1 parent d5223ca
lib/elelem/builtins/builtins.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:builtins) do |agent|
+  agent.commands.register("exit", description: "Exit elelem") { exit(0) }
+
+  agent.commands.register("clear", description: "Clear conversation history") do
+    agent.conversation.clear!
+    agent.terminal.say "  → context cleared"
+  end
+
+  agent.commands.register("context", description: "Show conversation context") do
+    agent.terminal.say JSON.pretty_generate(agent.context)
+  end
+
+  agent.commands.register("shell", description: "Start interactive shell") do
+    transcript = Tempfile.create do |file|
+      system("script", "-q", file.path, chdir: Dir.pwd)
+      File.read(file.path)
+        .gsub(/^Script started.*?\n/, "")
+        .gsub(/\nScript done.*$/, "")
+        .gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
+        .gsub(/\e\[\?[0-9]+[hl]/, "")
+        .gsub(/[\b]/, "")
+        .gsub(/\r/, "")
+    end
+    agent.conversation.add(role: "user", content: transcript) unless transcript.strip.empty?
+  end
+
+  agent.commands.register("init", description: "Generate AGENTS.md") do
+    system_prompt = <<~PROMPT
+      AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
+
+      # AGENTS.md Spec (https://agents.md/)
+      A file providing context and instructions for AI coding agents.
+
+      ## Recommended Sections
+      - Commands: build, test, lint commands
+      - Code Style: conventions, patterns
+      - Architecture: key components and flow
+      - Testing: how to run tests
+
+      ## Process
+      1. Read README.md if present
+      2. Identify language (Gemfile, package.json, go.mod)
+      3. Find test scripts (bin/test, npm test)
+      4. Check linter configs
+      5. Write concise AGENTS.md
+
+      Keep it minimal. No fluff.
+    PROMPT
+
+    sub = Agent.new(agent.client, toolbox: agent.toolbox, terminal: agent.terminal, system_prompt: system_prompt)
+    sub.turn("Generate AGENTS.md for this project")
+  end
+
+  agent.commands.register("reload", description: "Reload plugins and source") do
+    lib_dir = File.expand_path("../..", __dir__)
+    original_verbose, $VERBOSE = $VERBOSE, nil
+    Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
+    $VERBOSE = original_verbose
+    agent.toolbox = Elelem::Toolbox.new
+    agent.commands = Elelem::Commands.new
+    Elelem::Plugins.reload!(agent)
+  end
+
+  agent.commands.register("help", description: "Show available commands") do
+    agent.terminal.say agent.commands.names.join(" ")
+  end
+end
lib/elelem/builtins/confirm.rb
@@ -1,12 +1,11 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:confirm) do |toolbox|
-  toolbox.before("execute") do |args|
+Elelem::Plugins.register(:confirm) do |agent|
+  agent.toolbox.before("execute") do |args|
     next unless $stdin.tty?
 
     cmd = args["command"]
-    $stdout.print "  Allow? [Y/n] > "
-    answer = $stdin.gets&.strip&.downcase
+    answer = agent.terminal.ask("  Allow? [Y/n] > ")&.downcase
     raise "User denied permission to execute: #{cmd}" if answer == "n"
   end
 end
lib/elelem/builtins/edit.rb
@@ -1,14 +1,14 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:edit) do |toolbox|
-  toolbox.add("edit",
+Elelem::Plugins.register(:edit) do |agent|
+  agent.toolbox.add("edit",
     description: "Replace first occurrence of text in file",
     params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
     required: ["path", "old", "new"]
   ) do |a|
     path = Pathname.new(a["path"]).expand_path
     content = path.read
-    toolbox
+    agent.toolbox
       .run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
       .merge(replaced: a["old"], with: a["new"])
   end
lib/elelem/builtins/eval.rb
@@ -1,16 +1,16 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:eval) do |toolbox|
+Elelem::Plugins.register(:eval) do |agent|
   description = <<~'DESC'
     Evaluate Ruby code. Available API:
 
     name = "search"
-    toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
-      toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
+    agent.toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
+      agent.toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
     end
   DESC
 
-  toolbox.add("eval",
+  agent.toolbox.add("eval",
     description: description,
     params: { ruby: { type: "string" } },
     required: ["ruby"]
lib/elelem/builtins/execute.rb
@@ -1,18 +1,18 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:execute) do |toolbox|
-  toolbox.add("execute",
+Elelem::Plugins.register(:execute) do |agent|
+  agent.toolbox.add("execute",
     description: "Run shell command (supports pipes and redirections)",
     params: { command: { type: "string" } },
     required: ["command"],
     aliases: ["bash", "sh", "exec", "execute<|channel|>"]
   ) do |a|
-    Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) }
+    Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
   end
 
-  toolbox.after("execute") do |args, result|
+  agent.toolbox.after("execute") do |args, result|
     next if result[:exit_status] == 0
 
-    $stdout.puts toolbox.header("execute", args, state: "x")
+    agent.terminal.say agent.toolbox.header("execute", args, state: "x")
   end
 end
lib/elelem/builtins/mcp.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:mcp) do |toolbox|
+Elelem::Plugins.register(:mcp) do |agent|
   mcp = Elelem::MCP.new
   at_exit { mcp.close }
   mcp.tools.each do |name, tool|
-    toolbox.add(name,
+    agent.toolbox.add(name,
       description: tool[:description],
       params: tool[:params],
       required: tool[:required],
lib/elelem/builtins/read.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:read) do |toolbox|
-  toolbox.add("read",
+Elelem::Plugins.register(:read) do |agent|
+  agent.toolbox.add("read",
     description: "Read file",
     params: { path: { type: "string" } },
     required: ["path"],
@@ -11,11 +11,11 @@ Elelem::Plugins.register(:read) do |toolbox|
     path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
   end
 
-  toolbox.after("read") do |_, result|
+  agent.toolbox.after("read") do |_, result|
     if result[:error]
-      $stdout.puts "  ! #{result[:error]}"
-    elsif !system("bat", "--paging=never", result[:path])
-      $stdout.puts result[:content]
+      agent.terminal.say "  ! #{result[:error]}"
+    else
+      agent.terminal.display_file(result[:path], fallback: result[:content])
     end
   end
 end
lib/elelem/builtins/task.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:task) do |agent|
+  agent.toolbox.add("task",
+    description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
+    params: { prompt: { type: "string" } },
+    required: ["prompt"]
+  ) do |a|
+    sub = Elelem::Agent.new(agent.client, toolbox: agent.toolbox, terminal: agent.terminal,
+      system_prompt: "Research agent. Search, analyze, report. Be concise.")
+    sub.turn(a["prompt"])
+    { result: sub.conversation.last[:content] }
+  end
+end
lib/elelem/builtins/tools.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:tools) do |agent|
+  agent.commands.register("tools", description: "List available tools") do
+    agent.toolbox.tools.each_value do |tool|
+      agent.terminal.say ""
+      agent.terminal.say "  #{tool.name}"
+      agent.terminal.say "    #{tool.description}"
+      tool.params.each { |k, v| agent.terminal.say "      #{k}: #{v[:type] || v["type"]}" }
+      agent.terminal.say "    aliases: #{tool.aliases.join(", ")}" if tool.aliases.any?
+    end
+  end
+end
lib/elelem/builtins/verify.rb
@@ -27,16 +27,16 @@ module Elelem
     end
   end
 
-  Plugins.register(:verify) do |toolbox|
-    toolbox.add("verify",
+  Plugins.register(:verify) do |agent|
+    agent.toolbox.add("verify",
       description: "Verify file syntax and run tests",
       params: { path: { type: "string" } },
       required: ["path"]
     ) do |a|
       path = a["path"]
       Verifiers.for(path).inject({verified: []}) do |memo, cmd|
-        $stdout.puts toolbox.header("execute", { "command" => cmd })
-        v = toolbox.run("execute", { "command" => cmd })
+        agent.terminal.say agent.toolbox.header("execute", { "command" => cmd })
+        v = agent.toolbox.run("execute", { "command" => cmd })
         break v.merge(path: path, command: cmd) if v[:exit_status] != 0
 
         memo[:verified] << cmd
lib/elelem/builtins/write.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-Elelem::Plugins.register(:write) do |toolbox|
-  toolbox.add("write",
+Elelem::Plugins.register(:write) do |agent|
+  agent.toolbox.add("write",
     description: "Write file",
     params: { path: { type: "string" }, content: { type: "string" } },
     required: ["path", "content"],
@@ -12,12 +12,12 @@ Elelem::Plugins.register(:write) do |toolbox|
     { bytes: path.write(a["content"]), path: a["path"] }
   end
 
-  toolbox.after("write") do |_, result|
+  agent.toolbox.after("write") do |_, result|
     if result[:error]
-      $stdout.puts "  ! #{result[:error]}"
+      agent.terminal.say "  ! #{result[:error]}"
     else
-      system("bat", "--paging=never", result[:path]) || $stdout.puts("  -> #{result[:path]}")
-      toolbox.run("verify", { "path" => result[:path] })
+      agent.terminal.display_file(result[:path], fallback: "  -> #{result[:path]}")
+      agent.toolbox.run("verify", { "path" => result[:path] })
     end
   end
 end