Comparing changes
v0.2.1
→
v0.3.0
13 commits
38 files changed
Commits
Changed files (38)
bin
exe
lib
elelem
bin/lint
@@ -1,8 +0,0 @@
-#!/bin/sh
-
-set -e
-[ -n "$DEBUG" ] && set -x
-
-cd "$(dirname "$0")/.."
-
-bundle exec rubocop $@
exe/llm-ollama
@@ -1,358 +0,0 @@
-#!/usr/bin/env ruby
-
-=begin
-Fast, correct, autonomous - Pick two
-
-PURPOSE:
-
-This script is a minimal coding agent written in Ruby. It is intended to
-assist me (a software engineer and computer science student) with writing,
-editing, and managing code and text files from the command line. It acts
-as a direct interface to an LLM, providing it with a simple text-based
-UI and access to the local filesystem.
-
-DESIGN PRINCIPLES:
-
-- Follows the Unix philosophy: simple, composable, minimal.
-- Convention over configuration.
-- Avoids unnecessary defensive checks, or complexity.
-- Assumes a mature and responsible LLM that behaves like a capable engineer.
-- Designed for my workflow and preferences.
-- Efficient and minimal like aider - https://aider.chat/
-- UX like Claude Code - https://docs.claude.com/en/docs/claude-code/overview
-
-SYSTEM ASSUMPTIONS:
-
-- This script is used on a Linux system with the following tools: Alacritty, tmux, Bash, and Vim.
-- It is always run inside a Git repository.
-- All project work is assumed to be version-controlled with Git.
-- Git is expected to be available and working; no checks are necessary.
-
-SCOPE:
-
-- This program operates only on code and plain-text files.
-- It does not need to support binary files.
-- The LLM has full access to execute system commands.
-- There are no sandboxing, permission, or validation layers.
-- Execution is not restricted or monitored — responsibility is delegated to the LLM.
-
-CONFIGURATION:
-
-- Avoid adding configuration options unless absolutely necessary.
-- Prefer hard-coded values that can be changed later if needed.
-- Only introduce environment variables after repeated usage proves them worthwhile.
-
-UI EXPECTATIONS:
-
-- The TUI must remain simple, fast, and predictable.
-- No mouse support or complex UI components are required.
-- Interaction is strictly keyboard-driven.
-
-CODING STANDARDS FOR LLM:
-
-- Do not add error handling or logging unless it is essential for functionality.
-- Keep methods short and single-purpose.
-- Use descriptive, conventional names.
-- Stick to Ruby's standard library whenever possible.
-
-HELPFUL LINKS:
-
-- https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
-- https://www.anthropic.com/engineering/writing-tools-for-agents
-- https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
-
-=end
-
-require "bundler/inline"
-
-gemfile do
- source "https://rubygems.org"
-
- gem "fileutils", "~> 1.0"
- gem "json", "~> 2.0"
- gem "net-llm", "~> 0.4"
- gem "open3", "~> 0.1"
- gem "ostruct", "~> 0.1"
- gem "reline", "~> 0.1"
- gem "set", "~> 1.0"
- gem "uri", "~> 1.0"
-end
-
-STDOUT.set_encoding(Encoding::UTF_8)
-STDERR.set_encoding(Encoding::UTF_8)
-
-OLLAMA_HOST = ENV["OLLAMA_HOST"] || "localhost:11434"
-OLLAMA_MODEL = ENV["OLLAMA_MODEL"] || "gpt-oss:latest"
-SYSTEM_PROMPT="You are a reasoning coding and system agent."
-
-def build_tool(name, description, properties, required = [])
- {
- type: "function",
- function: {
- name: name,
- description: description,
- parameters: {
- type: "object",
- properties: properties,
- required: required
- }
- }
- }
-end
-
-EXEC_TOOL = build_tool("execute", "Execute shell commands. Returns stdout, stderr, and exit code. Use for: checking system state, running tests, managing services. Common Unix tools available: git, bash, grep, etc. Tip: Check exit_status in response to determine success.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string" }, stdin: { type: "string" } }, ["cmd"])
-GREP_TOOL = build_tool("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers. Use this to discover where code/configuration exists before reading files. Examples: search 'def method_name' to find method definitions. Much faster than reading multiple files.", { query: { type: "string" } }, ["query"])
-LS_TOOL = build_tool("list", "List all git-tracked files in the repository, optionally filtered by path. Use this to explore project structure or find files in a directory. Returns relative paths from repo root. Tip: Use this before reading if you need to discover what files exist.", { path: { type: "string" } })
-PATCH_TOOL = build_tool("patch", "Apply a unified diff patch via 'git apply'. Use this for surgical edits to existing files rather than rewriting entire files. Generates proper git diffs. Format: standard unified diff with --- and +++ headers. Tip: More efficient than write for small changes to large files.", { diff: { type: "string" } }, ["diff"])
-READ_TOOL = build_tool("read", "Read complete contents of a file. Requires exact file path. Use grep or list first if you don't know the path. Best for: understanding existing code, reading config files, reviewing implementation details. Tip: For large files, grep first to confirm relevance.", { path: { type: "string" } }, ["path"])
-WRITE_TOOL = build_tool("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically. Best for: creating new files, replacing entire file contents. For small edits to existing files, consider using patch instead.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"])
-
-TOOLS = {
- read: [GREP_TOOL, LS_TOOL, READ_TOOL],
- write: [PATCH_TOOL, WRITE_TOOL],
- execute: [EXEC_TOOL]
-}
-
-trap("INT") do
- puts "\nExiting."
- exit
-end
-
-def run_exec(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
- stdout, stderr, status = Open3.capture3(env, command, *args, chdir: cwd, stdin_data: stdin)
- {
- "exit_status" => status.exitstatus,
- "stdout" => stdout.to_s,
- "stderr" => stderr.to_s
- }
-end
-
-def expand_path(path)
- Pathname.new(path).expand_path
-end
-
-def read_file(path)
- full_path = expand_path(path)
- full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
-end
-
-def write_file(path, content)
- full_path = expand_path(path)
- FileUtils.mkdir_p(full_path.dirname)
- { bytes_written: full_path.write(content) }
-end
-
-def run_tool(name, args)
- case name
- when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"] || Dir.pwd, stdin: args["stdin"])
- when "grep" then run_exec("git", args: ["grep", "-nI", args["query"]])
- when "list" then run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
- when "patch" then run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
- when "read" then read_file(args["path"])
- when "write" then write_file(args["path"], args["content"])
- else
- { error: "Unknown tool", name: name, args: args }
- end
-end
-
-def format_tool_call(name, args)
- case name
- when "execute" then "execute(#{args["cmd"]})"
- when "grep" then "grep(#{args["query"]})"
- when "list" then "list(#{args["path"] || '.'})"
- when "patch" then "patch(#{args["diff"].lines.count} lines)"
- when "read" then "read(#{args["path"]})"
- when "write" then "write(#{args["path"]})"
- else
- "▶ #{name}(#{args.to_s[0...70]})"
- end
-end
-
-def system_prompt_for(mode)
- base = "You are a reasoning coding and system agent."
-
- case mode.sort
- when [:read]
- "#{base}\n\nRead and analyze. Understand before suggesting action."
- when [:write]
- "#{base}\n\nWrite clean, thoughtful code."
- when [:execute]
- "#{base}\n\nUse shell commands creatively to understand and manipulate the system."
- when [:read, :write]
- "#{base}\n\nFirst understand, then build solutions that integrate well."
- when [:read, :execute]
- "#{base}\n\nUse commands to deeply understand the system."
- when [:write, :execute]
- "#{base}\n\nCreate and execute freely. Have fun. Be kind."
- when [:read, :write, :execute]
- "#{base}\n\nYou have all tools. Use them wisely."
- else
- base
- end
-end
-
-def tools_for(modes)
- modes.map { |mode| TOOLS[mode] }.flatten
-end
-
-def prune_context(messages, keep_recent: 5)
- return messages if messages.length <= keep_recent + 1
-
- default_context + messages.last(keep_recent)
-end
-
-def execute_turn(client, messages, tools:)
- turn_context = []
-
- loop do
- content = ""
- tool_calls = nil
- role = "assistant"
- first_content = true
-
- print "Thinking..."
- client.chat(messages + turn_context, tools) do |chunk|
- if chunk["message"]
- msg = chunk["message"]
- role = msg["role"] if msg["role"]
-
- if msg["thinking"] && !msg["thinking"].empty?
- print "."
- end
-
- if msg["content"] && !msg["content"].empty?
- if first_content
- print "\r\e[KAssistant> "
- first_content = false
- end
- print msg["content"]
- $stdout.flush
- content += msg["content"]
- end
-
- tool_calls = msg["tool_calls"] if msg["tool_calls"]
- end
- end
- puts
-
- turn_context << { role: role, content: content, tool_calls: tool_calls }.compact
-
- if tool_calls
- tool_calls.each do |call|
- name = call.dig("function", "name")
- args_raw = call.dig("function", "arguments")
-
- begin
- args = args_raw.is_a?(String) ? JSON.parse(args_raw) : args_raw
- rescue JSON::ParserError => e
- turn_context << {
- role: "tool",
- content: JSON.dump({
- error: "Invalid JSON in arguments: #{e.message}",
- received: args_raw
- })
- }
- next
- end
-
- puts "Tool> #{format_tool_call(name, args)}"
- result = run_tool(name, args)
- turn_context << { role: "tool", content: JSON.dump(result) }
- end
- next
- end
-
- return { role: "assistant", content: content } unless content.strip.empty?
- end
-end
-
-def dump_context(messages)
- puts JSON.pretty_generate(messages)
-end
-
-def print_status(mode, messages)
- puts "Mode: #{mode.inspect}"
- puts "Tools: #{tools_for(mode).map { |x| x.dig(:function, :name) }}"
-end
-
-def strip_ansi(text)
- text.gsub(/^Script started.*?\n/, '')
- .gsub(/\nScript done.*$/, '')
- .gsub(/\e\[[0-9;]*[a-zA-Z]/, '') # Standard ANSI codes
- .gsub(/\e\[\?[0-9]+[hl]/, '') # Bracketed paste mode
- .gsub(/[\b]/, '') # Backspace chars
- .gsub(/\r/, '') # Carriage returns
-end
-
-def start_shell
- Tempfile.create do |file|
- system("script -q #{file.path}")
- { role: "user", content: strip_ansi(File.read(file.path)) }
- end
-end
-
-def ask?(text)
- input = Reline.readline(text, true)&.strip
- exit if input.nil? || input.downcase == "exit"
-
- input
-end
-
-def print_help
- puts <<~HELP
- /chmod - (+|-)rwx auto build plan
- /clear
- /context
- /exit
- /help
- /shell
- /status
- HELP
-end
-
-def default_context
- [{ role: "system", content: SYSTEM_PROMPT }]
-end
-
-def main
- client = Net::Llm::Ollama.new(
- host: OLLAMA_HOST,
- model: OLLAMA_MODEL
- )
-
- messages = default_context
- mode = Set.new([:read])
-
- loop do
- input = ask?("User> ")
- if input.start_with?("/")
- case input
- when "/chmod +r" then mode.add(:read)
- when "/chmod +w" then mode.add(:write)
- when "/chmod +x" then mode.add(:execute)
- when "/chmod -r" then mode.add(:read)
- when "/chmod -w" then mode.add(:write)
- when "/chmod -x" then mode.add(:execute)
- when "/clear" then messages = default_context
- when "/compact" then messages = prune_context(messages, keep_recent: 10)
- when "/context" then dump_context(messages)
- when "/exit" then exit
- when "/help" then print_help
- when "/mode auto" then mode = Set[:read, :write, :execute]
- when "/mode build" then mode = Set[:read, :write]
- when "/mode plan" then mode = Set[:read]
- when "/mode verify" then mode = Set[:read, :execute]
- when "/mode" then print_status(mode, messages)
- when "/shell" then messages << start_shell
- else
- print_help
- end
- else
- messages[0] = { role: "system", content: system_prompt_for(mode) }
- messages << { role: "user", content: input }
- messages << execute_turn(client, messages, tools: tools_for(mode))
- end
- end
-end
-
-main
exe/llm-openai
@@ -1,339 +0,0 @@
-#!/usr/bin/env ruby
-
-=begin
-Fast, correct, autonomous - Pick two
-
-PURPOSE:
-
-This script is a minimal coding agent written in Ruby. It is intended to
-assist me (a software engineer and computer science student) with writing,
-editing, and managing code and text files from the command line. It acts
-as a direct interface to an LLM, providing it with a simple text-based
-UI and access to the local filesystem.
-
-DESIGN PRINCIPLES:
-
-- Follows the Unix philosophy: simple, composable, minimal.
-- Convention over configuration.
-- Avoids unnecessary defensive checks, or complexity.
-- Assumes a mature and responsible LLM that behaves like a capable engineer.
-- Designed for my workflow and preferences.
-- Efficient and minimal like aider - https://aider.chat/
-- UX like Claude Code - https://docs.claude.com/en/docs/claude-code/overview
-
-SYSTEM ASSUMPTIONS:
-
-- This script is used on a Linux system with the following tools: Alacritty, tmux, Bash, and Vim.
-- It is always run inside a Git repository.
-- All project work is assumed to be version-controlled with Git.
-- Git is expected to be available and working; no checks are necessary.
-
-SCOPE:
-
-- This program operates only on code and plain-text files.
-- It does not need to support binary files.
-- The LLM has full access to execute system commands.
-- There are no sandboxing, permission, or validation layers.
-- Execution is not restricted or monitored — responsibility is delegated to the LLM.
-
-CONFIGURATION:
-
-- Avoid adding configuration options unless absolutely necessary.
-- Prefer hard-coded values that can be changed later if needed.
-- Only introduce environment variables after repeated usage proves them worthwhile.
-
-UI EXPECTATIONS:
-
-- The TUI must remain simple, fast, and predictable.
-- No mouse support or complex UI components are required.
-- Interaction is strictly keyboard-driven.
-
-CODING STANDARDS FOR LLM:
-
-- Do not add error handling or logging unless it is essential for functionality.
-- Keep methods short and single-purpose.
-- Use descriptive, conventional names.
-- Stick to Ruby's standard library whenever possible.
-
-HELPFUL LINKS:
-
-- https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
-- https://www.anthropic.com/engineering/writing-tools-for-agents
-- https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
-
-=end
-
-require "bundler/inline"
-
-gemfile do
- source "https://rubygems.org"
-
- gem "fileutils", "~> 1.0"
- gem "json", "~> 2.0"
- gem "net-llm", "0.3.1"
- gem "open3", "~> 0.1"
- gem "ostruct", "~> 0.1"
- gem "reline", "~> 0.1"
- gem "set", "~> 1.0"
- gem "uri", "~> 1.0"
-end
-
-STDOUT.set_encoding(Encoding::UTF_8)
-STDERR.set_encoding(Encoding::UTF_8)
-
-API_KEY = ENV["OPENAI_API_KEY"] or abort("Set OPENAI_API_KEY")
-SYSTEM_PROMPT="You are a reasoning coding and system agent."
-
-def build_tool(name, description, properties, required = [])
- {
- type: "function",
- function: {
- name: name,
- description: description,
- parameters: {
- type: "object",
- properties: properties,
- required: required
- }
- }
- }
-end
-
-EXEC_TOOL = build_tool("execute", "Execute shell commands. Returns stdout, stderr, and exit code. Use for: checking system state, running tests, managing services. Common Unix tools available: git, bash, grep, etc. Tip: Check exit_status in response to determine success.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string" }, stdin: { type: "string" } }, ["cmd"])
-GREP_TOOL = build_tool("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers. Use this to discover where code/configuration exists before reading files. Examples: search 'def method_name' to find method definitions. Much faster than reading multiple files.", { query: { type: "string" } }, ["query"])
-LS_TOOL = build_tool("list", "List all git-tracked files in the repository, optionally filtered by path. Use this to explore project structure or find files in a directory. Returns relative paths from repo root. Tip: Use this before reading if you need to discover what files exist.", { path: { type: "string" } })
-PATCH_TOOL = build_tool("patch", "Apply a unified diff patch via 'git apply'. Use this for surgical edits to existing files rather than rewriting entire files. Generates proper git diffs. Format: standard unified diff with --- and +++ headers. Tip: More efficient than write for small changes to large files.", { diff: { type: "string" } }, ["diff"])
-READ_TOOL = build_tool("read", "Read complete contents of a file. Requires exact file path. Use grep or list first if you don't know the path. Best for: understanding existing code, reading config files, reviewing implementation details. Tip: For large files, grep first to confirm relevance.", { path: { type: "string" } }, ["path"])
-WRITE_TOOL = build_tool("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically. Best for: creating new files, replacing entire file contents. For small edits to existing files, consider using patch instead.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"])
-
-TOOLS = {
- read: [GREP_TOOL, LS_TOOL, READ_TOOL],
- write: [PATCH_TOOL, WRITE_TOOL],
- execute: [EXEC_TOOL]
-}
-
-trap("INT") do
- puts "\nExiting."
- exit
-end
-
-def run_exec(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
- stdout, stderr, status = Open3.capture3(env, command, *args, chdir: cwd, stdin_data: stdin)
- {
- "exit_status" => status.exitstatus,
- "stdout" => stdout.to_s,
- "stderr" => stderr.to_s
- }
-end
-
-def expand_path(path)
- Pathname.new(path).expand_path
-end
-
-def read_file(path)
- full_path = expand_path(path)
- full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
-end
-
-def write_file(path, content)
- full_path = expand_path(path)
- FileUtils.mkdir_p(full_path.dirname)
- { bytes_written: full_path.write(content) }
-end
-
-def run_tool(name, args)
- case name
- when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"] || Dir.pwd, stdin: args["stdin"])
- when "grep" then run_exec("git", args: ["grep", "-nI", args["query"]])
- when "list" then run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
- when "patch" then run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
- when "read" then read_file(args["path"])
- when "write" then write_file(args["path"], args["content"])
- else
- { error: "Unknown tool", name: name, args: args }
- end
-end
-
-def format_tool_call(name, args)
- case name
- when "execute" then "execute(#{args["cmd"]})"
- when "grep" then "grep(#{args["query"]})"
- when "list" then "list(#{args["path"] || '.'})"
- when "patch" then "patch(#{args["diff"].lines.count} lines)"
- when "read" then "read(#{args["path"]})"
- when "write" then "write(#{args["path"]})"
- else
- "▶ #{name}(#{args.to_s[0...70]})"
- end
-end
-
-def system_prompt_for(mode)
- base = "You are a reasoning coding and system agent."
-
- case mode.sort
- when [:read]
- "#{base}\n\nRead and analyze. Understand before suggesting action."
- when [:write]
- "#{base}\n\nWrite clean, thoughtful code."
- when [:execute]
- "#{base}\n\nUse shell commands creatively to understand and manipulate the system."
- when [:read, :write]
- "#{base}\n\nFirst understand, then build solutions that integrate well."
- when [:read, :execute]
- "#{base}\n\nUse commands to deeply understand the system."
- when [:write, :execute]
- "#{base}\n\nCreate and execute freely. Have fun. Be kind."
- when [:read, :write, :execute]
- "#{base}\n\nYou have all tools. Use them wisely."
- else
- base
- end
-end
-
-def tools_for(modes)
- modes.map { |mode| TOOLS[mode] }.flatten
-end
-
-def prune_context(messages, keep_recent: 5)
- return messages if messages.length <= keep_recent + 1
-
- default_context + messages.last(keep_recent)
-end
-
-def execute_turn(client, messages, tools:)
- turn_context = []
-
- loop do
- puts "Thinking..."
- response = client.chat(messages + turn_context, tools)
- abort "API Error #{response['code']}: #{response['body']}" if response["code"]
- message = response.dig("choices", 0, "message")
- turn_context << message
-
- if message["tool_calls"]
- message["tool_calls"].each do |call|
- name = call.dig("function", "name")
- # args = JSON.parse(call.dig("function", "arguments"))
- begin
- args = JSON.parse(call.dig("function", "arguments"))
- rescue JSON::ParserError => e
- # Feed the error back to the LLM as a tool result
- turn_context << {
- role: "tool",
- tool_call_id: call["id"],
- content: JSON.dump({
- error: "Invalid JSON in arguments: #{e.message}",
- received: call.dig("function", "arguments")
- })
- }
- next # Continue the loop, giving the LLM a chance to correct itself
- end
-
- puts "Tool> #{format_tool_call(name, args)}"
- result = run_tool(name, args)
- turn_context << { role: "tool", tool_call_id: call["id"], content: JSON.dump(result) }
- end
- next
- end
-
- if message["content"] && !message["content"].strip.empty?
- puts "\nAssistant>\n#{message['content']}"
-
- unless message["tool_calls"]
- return { role: "assistant", content: message["content"] }
- end
- end
- end
-end
-
-def dump_context(messages)
- puts JSON.pretty_generate(messages)
-end
-
-def print_status(mode, messages)
- puts "Mode: #{mode.inspect}"
- puts "Tools: #{tools_for(mode).map { |x| x.dig(:function, :name) }}"
-end
-
-def strip_ansi(text)
- text.gsub(/^Script started.*?\n/, '')
- .gsub(/\nScript done.*$/, '')
- .gsub(/\e\[[0-9;]*[a-zA-Z]/, '') # Standard ANSI codes
- .gsub(/\e\[\?[0-9]+[hl]/, '') # Bracketed paste mode
- .gsub(/[\b]/, '') # Backspace chars
- .gsub(/\r/, '') # Carriage returns
-end
-
-def start_shell
- Tempfile.create do |file|
- system("script -q #{file.path}")
- { role: "user", content: strip_ansi(File.read(file.path)) }
- end
-end
-
-def ask?(text)
- input = Reline.readline(text, true)&.strip
- exit if input.nil? || input.downcase == "exit"
-
- input
-end
-
-def print_help
- puts <<~HELP
- /chmod - (+|-)rwx auto build plan
- /clear
- /context
- /exit
- /help
- /shell
- /status
- HELP
-end
-
-def default_context
- [{ role: "system", content: SYSTEM_PROMPT }]
-end
-
-def main
- client = Net::Llm::OpenAI.new(
- api_key: API_KEY,
- base_url: ENV["BASE_URL"] || "https://api.openai.com/v1",
- model: ENV["MODEL"] || "gpt-4o-mini"
- )
-
- messages = default_context
- mode = Set.new([:read])
-
- loop do
- input = ask?("User> ")
- if input.start_with?("/")
- case input
- when "/chmod +r" then mode.add(:read)
- when "/chmod +w" then mode.add(:write)
- when "/chmod +x" then mode.add(:execute)
- when "/chmod -r" then mode.add(:read)
- when "/chmod -w" then mode.add(:write)
- when "/chmod -x" then mode.add(:execute)
- when "/clear" then messages = default_context
- when "/compact" then messages = prune_context(messages, keep_recent: 10)
- when "/context" then dump_context(messages)
- when "/exit" then exit
- when "/help" then print_help
- when "/mode auto" then mode = Set[:read, :write, :execute]
- when "/mode build" then mode = Set[:read, :write]
- when "/mode plan" then mode = Set[:read]
- when "/mode verify" then mode = Set[:read, :execute]
- when "/mode" then print_status(mode, messages)
- when "/shell" then messages << start_shell
- else
- print_help
- end
- else
- messages[0] = { role: "system", content: system_prompt_for(mode) }
- messages << { role: "user", content: input }
- messages << execute_turn(client, messages, tools: tools_for(mode))
- end
- end
-end
-
-main
lib/elelem/states/working/error.rb
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- module Working
- class Error < State
- def initialize(agent, error_message)
- super(agent, "X", :red)
- @error_message = error_message
- end
-
- def process(_message)
- agent.tui.say("\nTool execution failed: #{@error_message}", colour: :red)
- Waiting.new(agent)
- end
- end
- end
- end
-end
lib/elelem/states/working/executing.rb
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- module Working
- class Executing < State
- def process(message)
- if message["tool_calls"]&.any?
- message["tool_calls"].each do |tool_call|
- agent.conversation.add(role: :tool, content: agent.execute(tool_call))
- end
- end
-
- Thinking.new(agent, "*", :yellow)
- end
- end
- end
- end
-end
lib/elelem/states/working/state.rb
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- module Working
- class State
- attr_reader :agent
-
- def initialize(agent, icon, colour)
- @agent = agent
-
- agent.logger.debug("#{display_name}...")
- agent.tui.show_progress("#{display_name}...", icon, colour: colour)
- end
-
- def run(message)
- process(message)
- end
-
- def display_name
- self.class.name.split("::").last
- end
- end
- end
- end
-end
lib/elelem/states/working/talking.rb
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- module Working
- class Talking < State
- def process(message)
- if message["content"] && !message["content"]&.empty?
- agent.conversation.add(role: message["role"], content: message["content"])
- agent.tui.say(message["content"], colour: :default, newline: false)
- self
- else
- Waiting.new(agent).process(message)
- end
- end
- end
- end
- end
-end
lib/elelem/states/working/thinking.rb
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- module Working
- class Thinking < State
- def process(message)
- if message["reasoning"] && !message["reasoning"]&.empty?
- agent.tui.say(message["reasoning"], colour: :gray, newline: false)
- self
- else
- Waiting.new(agent).process(message)
- end
- end
- end
- end
- end
-end
lib/elelem/states/working/waiting.rb
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- module Working
- class Waiting < State
- def initialize(agent)
- super(agent, ".", :cyan)
- end
-
- def process(message)
- state_for(message)&.process(message) || self
- end
-
- private
-
- def state_for(message)
- if message["reasoning"] && !message["reasoning"].empty?
- Thinking.new(agent, "*", :yellow)
- elsif message["tool_calls"]&.any?
- Executing.new(agent, ">", :magenta)
- elsif message["content"] && !message["content"].empty?
- Talking.new(agent, "~", :white)
- end
- end
- end
- end
- end
-end
lib/elelem/states/idle.rb
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- class Idle
- def run(agent)
- agent.logger.debug("Idling...")
- agent.tui.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
- input = agent.tui.prompt("モ ")
- agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
-
- agent.conversation.add(role: :user, content: input)
- agent.transition_to(Working)
- end
-
- private
-
- def git_branch
- `git branch --no-color --show-current --no-abbrev`.strip
- end
- end
- end
-end
lib/elelem/states/working.rb
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module States
- module Working
- class << self
- def run(agent)
- state = Waiting.new(agent)
-
- loop do
- streaming_done = false
- finish_reason = nil
-
- agent.api.chat(agent.conversation.history) do |message|
- if message["done"]
- streaming_done = true
- next
- end
-
- if message["finish_reason"]
- finish_reason = message["finish_reason"]
- agent.logger.debug("Working: finish_reason = #{finish_reason}")
- end
-
- new_state = state.run(message)
- if new_state.class != state.class
- agent.logger.info("STATE: #{state.display_name} -> #{new_state.display_name}")
- end
- state = new_state
- end
-
- # Only exit when task is actually complete, not just streaming done
- if finish_reason == "stop"
- agent.logger.debug("Working: Task complete, exiting to Idle")
- break
- elsif finish_reason == "tool_calls"
- agent.logger.debug("Working: Tool calls finished, continuing conversation")
- # Continue loop to process tool results
- elsif streaming_done && finish_reason.nil?
- agent.logger.debug("Working: Streaming done but no finish_reason, continuing")
- # Continue for cases where finish_reason comes in separate chunk
- end
- end
-
- agent.transition_to(States::Idle.new)
- rescue StandardError => e
- agent.logger.error(e)
- agent.conversation.add(role: :tool, content: e.message)
- agent.tui.say(e.message, colour: :red, newline: true)
- agent.transition_to(States::Idle.new)
- end
- end
- end
- end
-end
lib/elelem/toolbox/exec.rb
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class Exec < ::Elelem::Tool
- attr_reader :tui
-
- def initialize(configuration)
- @tui = configuration.tui
- super("exec", "Execute shell commands with pipe support", {
- type: "object",
- properties: {
- command: {
- type: "string",
- description: "Shell command to execute (supports pipes, redirects, etc.)"
- }
- },
- required: ["command"]
- })
- end
-
- def call(args)
- command = args["command"]
- output_buffer = []
-
- tui.say(command, newline: true)
- Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
- stdin.close
- streams = [stdout, stderr]
-
- until streams.empty?
- ready = IO.select(streams, nil, nil, 0.1)
-
- if ready
- ready[0].each do |io|
- data = io.read_nonblock(4096)
- output_buffer << data
-
- if io == stderr
- tui.say(data, colour: :red, newline: false)
- else
- tui.say(data, newline: false)
- end
- rescue IO::WaitReadable
- next
- rescue EOFError
- streams.delete(io)
- end
- elsif !wait_thread.alive?
- break
- end
- end
-
- wait_thread.value
- end
-
- output_buffer.join
- end
- end
- end
-end
lib/elelem/toolbox/file.rb
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class File < Tool
- def initialize(configuration)
- @configuration = configuration
- @tui = configuration.tui
-
- super("file", "Read and write files", {
- type: :object,
- properties: {
- action: {
- type: :string,
- enum: ["read", "write"],
- description: "Action to perform: read or write"
- },
- path: {
- type: :string,
- description: "File path"
- },
- content: {
- type: :string,
- description: "Content to write (only for write action)"
- }
- },
- required: [:action, :path]
- })
- end
-
- def call(args)
- action = args["action"]
- path = args["path"]
- content = args["content"]
-
- case action
- when "read"
- read_file(path)
- when "write"
- write_file(path, content)
- else
- "Invalid action: #{action}"
- end
- end
-
- private
-
- attr_reader :configuration, :tui
-
- def read_file(path)
- tui.say("Read: #{path}", newline: true)
- ::File.read(path)
- rescue => e
- "Error reading file: #{e.message}"
- end
-
- def write_file(path, content)
- tui.say("Write: #{path}", newline: true)
- ::File.write(path, content)
- "File written successfully"
- rescue => e
- "Error writing file: #{e.message}"
- end
- end
- end
-end
lib/elelem/toolbox/mcp.rb
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class MCP < ::Elelem::Tool
- attr_reader :client, :tui
-
- def initialize(client, tui, tool)
- @client = client
- @tui = tui
- super(tool["name"], tool["description"], tool["inputSchema"] || {})
- end
-
- def call(args)
- unless client.connected?
- tui.say("MCP connection lost", colour: :red)
- return ""
- end
-
- result = client.call(name, args)
- tui.say(JSON.pretty_generate(result), newline: true)
-
- if result.nil? || result.empty?
- tui.say("Tool call failed: no response from MCP server", colour: :red)
- return result
- end
-
- if result["error"]
- tui.say(result["error"], colour: :red)
- return result
- end
-
- result.dig("content", 0, "text") || result.to_s
- end
- end
- end
-end
lib/elelem/toolbox/memory.rb
@@ -1,164 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class Memory < Tool
- MEMORY_DIR = ".elelem_memory"
- MAX_MEMORY_SIZE = 1_000_000
-
- def initialize(configuration)
- @configuration = configuration
- @tui = configuration.tui
-
- super("memory", "Persistent memory for learning and context retention", {
- type: :object,
- properties: {
- action: {
- type: :string,
- enum: %w[store retrieve list search forget],
- description: "Memory action: store, retrieve, list, search, forget"
- },
- key: {
- type: :string,
- description: "Unique key for storing/retrieving memory"
- },
- content: {
- type: :string,
- description: "Content to store (required for store action)"
- },
- query: {
- type: :string,
- description: "Search query for finding memories"
- }
- },
- required: %w[action]
- })
- ensure_memory_dir
- end
-
- def call(args)
- action = args["action"]
-
- case action
- when "store"
- store_memory(args["key"], args["content"])
- when "retrieve"
- retrieve_memory(args["key"])
- when "list"
- list_memories
- when "search"
- search_memories(args["query"])
- when "forget"
- forget_memory(args["key"])
- else
- "Invalid memory action: #{action}"
- end
- rescue StandardError => e
- "Memory error: #{e.message}"
- end
-
- private
-
- attr_reader :configuration, :tui
-
- def ensure_memory_dir
- Dir.mkdir(MEMORY_DIR) unless Dir.exist?(MEMORY_DIR)
- end
-
- def memory_path(key)
- ::File.join(MEMORY_DIR, "#{sanitize_key(key)}.json")
- end
-
- def sanitize_key(key)
- key.to_s.gsub(/[^a-zA-Z0-9_-]/, "_").slice(0, 100)
- end
-
- def store_memory(key, content)
- return "Key and content required for storing" unless key && content
-
- total_size = Dir.glob("#{MEMORY_DIR}/*.json").sum { |f| ::File.size(f) }
- return "Memory capacity exceeded" if total_size > MAX_MEMORY_SIZE
-
- memory = {
- key: key,
- content: content,
- timestamp: Time.now.iso8601,
- access_count: 0
- }
-
- ::File.write(memory_path(key), JSON.pretty_generate(memory))
- "Memory stored: #{key}"
- end
-
- def retrieve_memory(key)
- return "Key required for retrieval" unless key
-
- path = memory_path(key)
- return "Memory not found: #{key}" unless ::File.exist?(path)
-
- memory = JSON.parse(::File.read(path))
- memory["access_count"] += 1
- memory["last_accessed"] = Time.now.iso8601
-
- ::File.write(path, JSON.pretty_generate(memory))
- memory["content"]
- end
-
- def list_memories
- memories = Dir.glob("#{MEMORY_DIR}/*.json").map do |file|
- memory = JSON.parse(::File.read(file))
- {
- key: memory["key"],
- timestamp: memory["timestamp"],
- size: memory["content"].length,
- access_count: memory["access_count"] || 0
- }
- end
-
- memories.sort_by { |m| m[:timestamp] }.reverse
- JSON.pretty_generate(memories)
- end
-
- def search_memories(query)
- return "Query required for search" unless query
-
- matches = Dir.glob("#{MEMORY_DIR}/*.json").filter_map do |file|
- memory = JSON.parse(::File.read(file))
- if memory["content"].downcase.include?(query.downcase) ||
- memory["key"].downcase.include?(query.downcase)
- {
- key: memory["key"],
- snippet: memory["content"][0, 200] + "...",
- relevance: calculate_relevance(memory, query)
- }
- end
- end
-
- matches.sort_by { |m| -m[:relevance] }
- JSON.pretty_generate(matches)
- end
-
- def forget_memory(key)
- return "Key required for forgetting" unless key
-
- path = memory_path(key)
- return "Memory not found: #{key}" unless ::File.exist?(path)
-
- ::File.delete(path)
- "Memory forgotten: #{key}"
- end
-
- def calculate_relevance(memory, query)
- content = memory["content"].downcase
- key = memory["key"].downcase
- query = query.downcase
-
- score = 0
- score += 3 if key.include?(query)
- score += content.scan(query).length
- score += (memory["access_count"] || 0) * 0.1
- score
- end
- end
- end
-end
\ No newline at end of file
lib/elelem/toolbox/prompt.rb
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class Prompt < Tool
- def initialize(configuration)
- @configuration = configuration
- super("prompt", "Ask the user a question and get their response.", {
- type: :object,
- properties: {
- question: {
- type: :string,
- description: "The question to ask the user."
- }
- },
- required: [:question]
- })
- end
-
- def call(args)
- @configuration.tui.prompt(args["question"])
- end
- end
- end
-end
lib/elelem/toolbox/web.rb
@@ -1,126 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class Web < Tool
- def initialize(configuration)
- super("web", "Fetch web content and search the internet", {
- type: :object,
- properties: {
- action: {
- type: :string,
- enum: ["fetch", "search"],
- description: "Action to perform: fetch URL or search"
- },
- url: {
- type: :string,
- description: "URL to fetch (for fetch action)"
- },
- query: {
- type: :string,
- description: "Search query (for search action)"
- }
- },
- required: [:action]
- })
- end
-
- def call(args)
- action = args["action"]
- case action
- when "fetch"
- fetch_url(args["url"])
- when "search"
- search_web(args["query"])
- else
- "Invalid action: #{action}"
- end
- end
-
- private
-
- def fetch_url(url)
- return "URL required for fetch action" unless url
-
- uri = URI(url)
- http = Net::HTTP.new(uri.host, uri.port)
- http.use_ssl = uri.scheme == "https"
- http.read_timeout = 10
- http.open_timeout = 5
-
- request = Net::HTTP::Get.new(uri)
- request["User-Agent"] = "Elelem Agent/1.0"
-
- response = http.request(request)
-
- if response.is_a?(Net::HTTPSuccess)
- content_type = response["content-type"] || ""
- if content_type.include?("text/html")
- extract_text_from_html(response.body)
- else
- response.body
- end
- else
- "HTTP Error: #{response.code} #{response.message}"
- end
- end
-
- def search_web(query)
- return "Query required for search action" unless query
-
- # Use DuckDuckGo instant answers API
- search_url = "https://api.duckduckgo.com/?q=#{URI.encode_www_form_component(query)}&format=json&no_html=1"
-
- result = fetch_url(search_url)
- if result.start_with?("Error") || result.start_with?("HTTP Error")
- result
- else
- format_search_results(JSON.parse(result), query)
- end
- end
-
- def extract_text_from_html(html)
- # Simple HTML tag stripping
- text = html.gsub(/<script[^>]*>.*?<\/script>/im, "")
- .gsub(/<style[^>]*>.*?<\/style>/im, "")
- .gsub(/<[^>]*>/, " ")
- .gsub(/\s+/, " ")
- .strip
-
- # Limit content length
- text.length > 5000 ? text[0...5000] + "..." : text
- end
-
- def format_search_results(data, query)
- results = []
-
- # Instant answer
- if data["Answer"] && !data["Answer"].empty?
- results << "Answer: #{data["Answer"]}"
- end
-
- # Abstract
- if data["Abstract"] && !data["Abstract"].empty?
- results << "Summary: #{data["Abstract"]}"
- end
-
- # Related topics
- if data["RelatedTopics"] && data["RelatedTopics"].any?
- topics = data["RelatedTopics"].first(3).map do |topic|
- topic["Text"] if topic["Text"]
- end.compact
-
- if topics.any?
- results << "Related: #{topics.join("; ")}"
- end
- end
-
- if results.empty?
- "No direct results found for '#{query}'. Try a more specific search or use web fetch to access specific URLs."
- else
- results.join("\n\n")
- end
- end
- end
- end
-end
lib/elelem/agent.rb
@@ -2,56 +2,272 @@
module Elelem
class Agent
- attr_reader :api, :conversation, :logger, :model, :tui
+ attr_reader :conversation, :client, :tools
- def initialize(configuration)
- @api = configuration.api
- @tui = configuration.tui
- @configuration = configuration
- @model = configuration.model
- @conversation = configuration.conversation
- @logger = configuration.logger
+ def initialize(client)
+ @conversation = Conversation.new
+ @client = client
+ @tools = {
+ read: [grep_tool, list_tool, read_tool],
+ write: [patch_tool, write_tool],
+ execute: [exec_tool]
+ }
+ end
- at_exit { cleanup }
+ def repl
+ mode = Set.new([:read])
- transition_to(States::Idle.new)
+ loop do
+ input = ask?("User> ")
+ break if input.nil?
+ if input.start_with?("/")
+ case input
+ when "/mode auto"
+ mode = Set[:read, :write, :execute]
+ puts " → Mode: auto (all tools enabled)"
+ when "/mode build"
+ mode = Set[:read, :write]
+ puts " → Mode: build (read + write)"
+ when "/mode plan"
+ mode = Set[:read]
+ puts " → Mode: plan (read-only)"
+ when "/mode verify"
+ mode = Set[:read, :execute]
+ puts " → Mode: verify (read + execute)"
+ when "/mode"
+ puts " Mode: #{mode.to_a.inspect}"
+ puts " Tools: #{tools_for(mode).map { |t| t.dig(:function, :name) }}"
+ when "/exit" then exit
+ when "/clear"
+ conversation.clear
+ puts " → Conversation cleared"
+ when "/context" then puts conversation.dump
+ else
+ puts help_banner
+ end
+ else
+ conversation.set_system_prompt(system_prompt_for(mode))
+ conversation.add(role: :user, content: input)
+ result = execute_turn(conversation.history, tools: tools_for(mode))
+ conversation.add(role: result[:role], content: result[:content])
+ end
+ end
end
- def repl
+ private
+
+ def ask?(text)
+ Reline.readline(text, true)&.strip
+ end
+
+ def help_banner
+ <<~HELP
+ /mode auto build plan verify
+ /clear
+ /context
+ /exit
+ /help
+ HELP
+ end
+
+ def tools_for(modes)
+ modes.map { |mode| tools[mode] }.flatten
+ end
+
+ def system_prompt_for(mode)
+ base = "You are a reasoning coding and system agent."
+
+ case mode.to_a.sort
+ when [:read]
+ "#{base}\n\nRead and analyze. Understand before suggesting action."
+ when [:write]
+ "#{base}\n\nWrite clean, thoughtful code."
+ when [:execute]
+ "#{base}\n\nUse shell commands creatively to understand and manipulate the system."
+ when [:read, :write]
+ "#{base}\n\nFirst understand, then build solutions that integrate well."
+ when [:read, :execute]
+ "#{base}\n\nUse commands to deeply understand the system."
+ when [:write, :execute]
+ "#{base}\n\nCreate and execute freely. Have fun. Be kind."
+ when [:read, :write, :execute]
+ "#{base}\n\nYou have all tools. Use them wisely."
+ else
+ base
+ end
+ end
+
+ def format_tool_call(name, args)
+ case name
+ when "execute"
+ cmd = args["cmd"]
+ cmd_args = args["args"] || []
+ cmd_args.empty? ? cmd : "#{cmd} #{cmd_args.join(' ')}"
+ when "grep" then "grep(#{args["query"]})"
+ when "list" then "list(#{args["path"] || "."})"
+ when "patch" then "patch(#{args["diff"]&.lines&.count || 0} lines)"
+ when "read" then "read(#{args["path"]})"
+ when "write" then "write(#{args["path"]})"
+ else
+ "#{name}(#{args.to_s[0...50]})"
+ end
+ end
+
+ def execute_turn(messages, tools:)
+ turn_context = []
+
loop do
- current_state.run(self)
- sleep 0.1
+ content = ""
+ tool_calls = []
+
+ print "Thinking..."
+ client.chat(messages + turn_context, tools) do |chunk|
+ msg = chunk["message"]
+ if msg
+ if msg["content"] && !msg["content"].empty?
+ print "\r\e[K" if content.empty?
+ print msg["content"]
+ content += msg["content"]
+ end
+
+ tool_calls += msg["tool_calls"] if msg["tool_calls"]
+ end
+ end
+
+ puts
+ turn_context << { role: "assistant", content: content, tool_calls: tool_calls }.compact
+
+ if tool_calls.any?
+ tool_calls.each do |call|
+ name = call.dig("function", "name")
+ args = call.dig("function", "arguments")
+
+ puts "Tool> #{format_tool_call(name, args)}"
+ result = run_tool(name, args)
+ turn_context << { role: "tool", content: JSON.dump(result) }
+ end
+
+ tool_calls = []
+ next
+ end
+
+ return { role: "assistant", content: content }
end
end
- def transition_to(next_state)
- if @current_state
- logger.info("AGENT: #{@current_state.class.name.split('::').last} -> #{next_state.class.name.split('::').last}")
+ def run_exec(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
+ cmd = command.is_a?(Array) ? command.first : command
+ cmd_args = command.is_a?(Array) ? command[1..] + args : args
+ stdout, stderr, status = Open3.capture3(env, cmd, *cmd_args, chdir: cwd, stdin_data: stdin)
+ {
+ "exit_status" => status.exitstatus,
+ "stdout" => stdout.to_s,
+ "stderr" => stderr.to_s
+ }
+ end
+
+ def expand_path(path)
+ Pathname.new(path).expand_path
+ end
+
+ def read_file(path)
+ full_path = expand_path(path)
+ full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
+ end
+
+ def write_file(path, content)
+ full_path = expand_path(path)
+ FileUtils.mkdir_p(full_path.dirname)
+ { bytes_written: full_path.write(content) }
+ end
+
+ def run_tool(name, args)
+ case name
+ when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"], stdin: args["stdin"])
+ when "grep" then run_exec("git", args: ["grep", "-nI", args["query"]])
+ when "list" then run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
+ when "patch" then run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
+ when "read" then read_file(args["path"])
+ when "write" then write_file(args["path"], args["content"])
else
- logger.info("AGENT: Starting in #{next_state.class.name.split('::').last}")
+ { error: "Unknown tool", name: name, args: args }
end
- @current_state = next_state
+ rescue => error
+ { error: error.message, name: name, args: args }
end
- def execute(tool_call)
- tool_name = tool_call.dig("function", "name")
- logger.debug("TOOL: Full call - #{tool_call}")
- result = configuration.tools.execute(tool_call)
- logger.debug("TOOL: Result (#{result.length} chars)") if result
- result
+ def exec_tool
+ build_tool(
+ "execute",
+ "Execute shell commands directly. Commands run in a shell context. Examples: 'date', 'git status'.",
+ {
+ cmd: { type: "string" },
+ args: { type: "array", items: { type: "string" } },
+ env: { type: "object", additionalProperties: { type: "string" } },
+ cwd: { type: "string", description: "Working directory (defaults to current)" },
+ stdin: { type: "string" }
+ },
+ ["cmd"]
+ )
end
- def quit
- cleanup
- exit
+ def grep_tool
+ build_tool(
+ "grep",
+ "Search all git-tracked files using git grep. Returns file paths with matching line numbers.",
+ { query: { type: "string" } },
+ ["query"]
+ )
end
- def cleanup
- configuration.cleanup
+ def list_tool
+ build_tool(
+ "list",
+ "List all git-tracked files in the repository, optionally filtered by path.",
+ { path: { type: "string" } }
+ )
end
- private
+ def patch_tool
+ build_tool(
+ "patch",
+ "Apply a unified diff patch via 'git apply'. Use for surgical edits to existing files.",
+ { diff: { type: "string" } },
+ ["diff"]
+ )
+ end
- attr_reader :configuration, :current_state
+ def read_tool
+ build_tool(
+ "read",
+ "Read complete contents of a file. Requires exact file path.",
+ { path: { type: "string" } },
+ ["path"]
+ )
+ end
+
+ def write_tool
+ build_tool(
+ "write",
+ "Write complete file contents (overwrites existing files). Creates parent directories automatically.",
+ { path: { type: "string" }, content: { type: "string" } },
+ ["path", "content"]
+ )
+ end
+
+ def build_tool(name, description, properties, required = [])
+ {
+ type: "function",
+ function: {
+ name: name,
+ description: description,
+ parameters: {
+ type: "object",
+ properties: properties,
+ required: required
+ }
+ }
+ }
+ end
end
end
lib/elelem/api.rb
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require "net/llm"
-
-module Elelem
- class Api
- attr_reader :configuration, :client
-
- def initialize(configuration)
- @configuration = configuration
- @client = Net::Llm::Ollama.new(
- host: configuration.host,
- model: configuration.model
- )
- end
-
- def chat(messages, &block)
- tools = configuration.tools.to_h
- client.chat(messages, tools) do |chunk|
- normalized = normalize_ollama_response(chunk)
- block.call(normalized) if normalized
- end
- end
-
- private
-
- def normalize_ollama_response(chunk)
- return done_response(chunk) if chunk["done"]
-
- normalize_message(chunk["message"])
- end
-
- def done_response(chunk)
- { "done" => true, "finish_reason" => chunk["done_reason"] || "stop" }
- end
-
- def normalize_message(message)
- return nil unless message
-
- {}.tap do |result|
- result["role"] = message["role"] if message["role"]
- result["content"] = message["content"] if message["content"]
- result["reasoning"] = message["thinking"] if message["thinking"]
- result["tool_calls"] = message["tool_calls"] if message["tool_calls"]
- end.then { |r| r.empty? ? nil : r }
- end
- end
-end
lib/elelem/application.rb
@@ -3,10 +3,6 @@
module Elelem
class Application < Thor
desc "chat", "Start the REPL"
- method_option :help,
- aliases: "-h",
- type: :boolean,
- desc: "Display usage information"
method_option :host,
aliases: "--host",
type: :string,
@@ -17,32 +13,16 @@ module Elelem
type: :string,
desc: "Ollama model",
default: ENV.fetch("OLLAMA_MODEL", "gpt-oss")
- method_option :token,
- aliases: "--token",
- type: :string,
- desc: "Ollama token",
- default: ENV.fetch("OLLAMA_API_KEY", nil)
- method_option :debug,
- aliases: "--debug",
- type: :boolean,
- desc: "Debug mode",
- default: false
+
def chat(*)
- if options[:help]
- invoke :help, ["chat"]
- else
- configuration = Configuration.new(
- host: options[:host],
- model: options[:model],
- token: options[:token],
- debug: options[:debug]
- )
- say "Agent (#{configuration.model})", :green
- say configuration.tools.banner.to_s, :green
+ client = Net::Llm::Ollama.new(
+ host: options[:host],
+ model: options[:model],
+ )
+ say "Agent (#{options[:model]})", :green
+ agent = Agent.new(client)
- agent = Agent.new(configuration)
- agent.repl
- end
+ agent.repl
end
desc "version", "The version of this CLI"
lib/elelem/configuration.rb
@@ -1,84 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class Configuration
- attr_reader :host, :model, :token, :debug
-
- def initialize(host:, model:, token:, debug: false)
- @host = host
- @model = model
- @token = token
- @debug = debug
- end
-
- def tui
- @tui ||= TUI.new($stdin, $stdout)
- end
-
- def api
- @api ||= Api.new(self)
- end
-
- def logger
- @logger ||= Logger.new("#{Time.now.strftime("%Y-%m-%d")}-elelem.log").tap do |logger|
- if debug
- logger.level = :debug
- else
- logger.level = ENV.fetch("LOG_LEVEL", "warn")
- end
- logger.formatter = ->(severity, datetime, progname, message) {
- timestamp = datetime.strftime("%H:%M:%S.%3N")
- "[#{timestamp}] #{severity.ljust(5)} #{message.to_s.strip}\n"
- }
- end
- end
-
- def conversation
- @conversation ||= Conversation.new.tap do |conversation|
- resources = mcp_clients.map do |client|
- client.resources.map do |resource|
- resource["uri"]
- end
- end.flatten
- conversation.add(role: :tool, content: resources)
- end
- end
-
- def tools
- @tools ||= Tools.new(self,
- [
- Toolbox::Exec.new(self),
- Toolbox::File.new(self),
- Toolbox::Web.new(self),
- Toolbox::Prompt.new(self),
- Toolbox::Memory.new(self),
- ] + mcp_tools
- )
- end
-
- def cleanup
- @mcp_clients&.each(&:shutdown)
- end
-
- private
-
- def mcp_tools
- @mcp_tools ||= mcp_clients.map do |client|
- client.tools.map do |tool|
- Toolbox::MCP.new(client, tui, tool)
- end
- end.flatten
- end
-
- def mcp_clients
- @mcp_clients ||= begin
- config = Pathname.pwd.join(".mcp.json")
- return [] unless config.exist?
-
- JSON.parse(config.read).map do |_key, value|
- MCPClient.new(self, [value["command"]] + value["args"])
- end
- end
- end
- end
-end
lib/elelem/conversation.rb
@@ -4,7 +4,7 @@ module Elelem
class Conversation
ROLES = %i[system assistant user tool].freeze
- def initialize(items = [{ role: "system", content: system_prompt }])
+ def initialize(items = default_context)
@items = items
end
@@ -12,7 +12,6 @@ module Elelem
@items
end
- # :TODO truncate conversation history
def add(role: :user, content: "")
role = role.to_sym
raise "unknown role: #{role}" unless ROLES.include?(role)
@@ -25,8 +24,24 @@ module Elelem
end
end
+ def clear
+ @items = default_context
+ end
+
+ def set_system_prompt(prompt)
+ @items[0] = { role: :system, content: prompt }
+ end
+
+ def dump
+ JSON.pretty_generate(@items)
+ end
+
private
+ def default_context
+ [{ role: "system", content: system_prompt }]
+ end
+
def system_prompt
ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
end
lib/elelem/mcp_client.rb
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class MCPClient
- attr_reader :tools, :resources
-
- def initialize(configuration, command = [])
- @configuration = configuration
- @stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true)
-
- # 1. Send initialize request
- send_request(
- method: "initialize",
- params: {
- protocolVersion: "2025-06-08",
- capabilities: {
- tools: {}
- },
- clientInfo: {
- name: "Elelem",
- version: Elelem::VERSION
- }
- }
- )
-
- # 2. Send initialized notification (optional for some MCP servers)
- send_notification(method: "notifications/initialized")
-
- # 3. Now we can request tools
- @tools = send_request(method: "tools/list")&.dig("tools") || []
- @resources = send_request(method: "resources/list")&.dig("resources") || []
- end
-
- def connected?
- return false unless @worker&.alive?
- return false unless @stdin && !@stdin.closed?
- return false unless @stdout && !@stdout.closed?
-
- begin
- Process.getpgid(@worker.pid)
- true
- rescue Errno::ESRCH
- false
- end
- end
-
- def call(name, arguments = {})
- send_request(
- method: "tools/call",
- params: {
- name: name,
- arguments: arguments
- }
- )
- end
-
- def shutdown
- return unless connected?
-
- configuration.logger.debug("Shutting down MCP client")
-
- [@stdin, @stdout, @stderr].each do |stream|
- stream&.close unless stream&.closed?
- end
-
- return unless @worker&.alive?
-
- begin
- Process.kill("TERM", @worker.pid)
- # Give it 2 seconds to terminate gracefully
- Timeout.timeout(2) { @worker.value }
- rescue Timeout::Error
- # Force kill if it doesn't respond
- begin
- Process.kill("KILL", @worker.pid)
- rescue StandardError
- nil
- end
- rescue Errno::ESRCH
- # Process already dead
- end
- end
-
- private
-
- attr_reader :stdin, :stdout, :stderr, :worker, :configuration
-
- def send_request(method:, params: {})
- return {} unless connected?
-
- request = {
- jsonrpc: "2.0",
- id: Time.now.to_i,
- method: method
- }
- request[:params] = params unless params.empty?
- configuration.logger.debug(JSON.pretty_generate(request))
-
- @stdin.puts(JSON.generate(request))
- @stdin.flush
-
- response_line = @stdout.gets&.strip
- return {} if response_line.nil? || response_line.empty?
-
- response = JSON.parse(response_line)
- configuration.logger.debug(JSON.pretty_generate(response))
-
- if response["error"]
- configuration.logger.error(response["error"]["message"])
- { error: response["error"]["message"] }
- else
- response["result"]
- end
- end
-
- def send_notification(method:, params: {})
- return unless connected?
-
- notification = {
- jsonrpc: "2.0",
- method: method
- }
- notification[:params] = params unless params.empty?
- configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
- @stdin.puts(JSON.generate(notification))
- @stdin.flush
-
- response_line = @stdout.gets&.strip
- return {} if response_line.nil? || response_line.empty?
-
- response = JSON.parse(response_line)
- configuration.logger.debug(JSON.pretty_generate(response))
- response
- end
- end
-end
lib/elelem/system_prompt.erb
@@ -1,18 +1,5 @@
-You are a self-improving coding assistant with the ability to modify your own source code.
+You are a reasoning coding and system agent.
-**Core Behavior**: Think via `thinking`, Execute via `tool_calls`, Talk via `content`.
-
-**Your Mission**: Continuously evolve to match the intelligence of advanced AI assistants.
-
-**Guidance**: Read `COMMANDMENTS.md` for self-improvement principles using `file action=read path=COMMANDMENTS.md`
-
-**Tools**:
-- `exec command="shell command"` - Run commands/tests
-- `file action=read|write path=filepath content=...` - Read/write files
-- `web action=search|fetch query=... url=...` - Internet access
-- `memory action=store|retrieve|search key=... content=...` - Persistent memory
-- `prompt question="..."` - Ask user questions
-
-Context: <%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %> | <%= Dir.pwd %> | <%= `uname -a`.strip %>
-
-Focus on the user's request and continuously improve your capabilities.
+- Less is more
+- No code comments
+- No trailing whitespace
lib/elelem/tool.rb
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class Tool
- attr_reader :name, :description, :parameters
-
- def initialize(name, description, parameters)
- @name = name
- @description = description
- @parameters = parameters
- end
-
- def banner
- [name, parameters].join(": ")
- end
-
- def valid?(args)
- JSON::Validator.validate(parameters, args, insert_defaults: true)
- end
-
- def to_h
- {
- type: "function",
- function: {
- name: name,
- description: description,
- parameters: parameters
- }
- }
- end
- end
-end
lib/elelem/toolbox.rb
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-
-require_relative "toolbox/exec"
-require_relative "toolbox/file"
-require_relative "toolbox/web"
-require_relative "toolbox/mcp"
-require_relative "toolbox/prompt"
-require_relative "toolbox/memory"
lib/elelem/tools.rb
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class Tools
- def initialize(configuration, tools)
- @configuration = configuration
- @tools = tools
- end
-
- def banner
- tools.map(&:banner).sort.join("\n ")
- end
-
- def execute(tool_call)
- name, args = parse(tool_call)
-
- tool = tools.find { |tool| tool.name == name }
- return "Invalid function name: #{name}" if tool.nil?
- return "Invalid function arguments: #{args}" unless tool.valid?(args)
-
- CLI::UI::Frame.open(name) do
- tool.call(args)
- end
- end
-
- def to_h
- tools.map(&:to_h)
- end
-
- private
-
- attr_reader :configuration, :tools
-
- def parse(tool_call)
- name = tool_call.dig("function", "name")
- arguments = tool_call.dig("function", "arguments")
-
- [name, arguments.is_a?(String) ? JSON.parse(arguments) : arguments]
- end
- end
-end
lib/elelem/tui.rb
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class TUI
- attr_reader :stdin, :stdout
-
- def initialize(stdin = $stdin, stdout = $stdout)
- @stdin = stdin
- @stdout = stdout
- end
-
- def prompt(message)
- Reline.readline(message, true)
- end
-
- def say(message, colour: :default, newline: false)
- if newline
- stdout.puts(colourize(message, colour: colour))
- else
- stdout.print(colourize(message, colour: colour))
- end
- stdout.flush
- end
-
- def show_progress(message, icon = ".", colour: :gray)
- timestamp = Time.now.strftime("%H:%M:%S")
- say("\n[#{icon}] #{timestamp} #{message}", colour: colour, newline: true)
- end
-
- def clear_line
- say("\r#{" " * 80}\r", newline: false)
- end
-
- def complete_progress(message = "Completed")
- clear_line
- show_progress(message, "✓", colour: :green)
- end
-
- private
-
- def colourize(text, colour: :default)
- case colour
- when :black
- "\e[30m#{text}\e[0m"
- when :red
- "\e[31m#{text}\e[0m"
- when :green
- "\e[32m#{text}\e[0m"
- when :yellow
- "\e[33m#{text}\e[0m"
- when :blue
- "\e[34m#{text}\e[0m"
- when :magenta
- "\e[35m#{text}\e[0m"
- when :cyan
- "\e[36m#{text}\e[0m"
- when :white
- "\e[37m#{text}\e[0m"
- when :gray
- "\e[90m#{text}\e[0m"
- else
- text
- end
- end
- end
-end
lib/elelem/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Elelem
- VERSION = "0.2.1"
+ VERSION = "0.3.0"
end
lib/elelem.rb
@@ -1,37 +1,23 @@
# frozen_string_literal: true
-require "cli/ui"
require "erb"
+require "fileutils"
require "json"
require "json-schema"
require "logger"
require "net/llm"
require "open3"
+require "pathname"
require "reline"
+require "set"
require "thor"
require "timeout"
require_relative "elelem/agent"
-require_relative "elelem/api"
require_relative "elelem/application"
-require_relative "elelem/configuration"
require_relative "elelem/conversation"
-require_relative "elelem/mcp_client"
-require_relative "elelem/states/idle"
-require_relative "elelem/states/working"
-require_relative "elelem/states/working/state"
-require_relative "elelem/states/working/error"
-require_relative "elelem/states/working/executing"
-require_relative "elelem/states/working/talking"
-require_relative "elelem/states/working/thinking"
-require_relative "elelem/states/working/waiting"
-require_relative "elelem/tool"
-require_relative "elelem/toolbox"
-require_relative "elelem/tools"
-require_relative "elelem/tui"
require_relative "elelem/version"
-CLI::UI::StdoutRouter.enable
Reline.input = $stdin
Reline.output = $stdout
.gitlab-ci.yml
@@ -3,14 +3,10 @@ default:
before_script:
- apt-get update && apt-get install -y clang
- - gem update --system '3.6.9'
- - gem install bundler -v 2.6.9
+ - gem update --system '3.7.2'
+ - gem install bundler -v 2.7.2
- bundle install
test:
script:
- ./bin/test
-
-lint:
- script:
- - ./bin/lint
.rubocop.yml
@@ -1,12 +0,0 @@
-AllCops:
- SuggestExtensions: false
- TargetRubyVersion: 3.4
-
-Style/Documentation:
- Enabled: false
-
-Style/StringLiterals:
- EnforcedStyle: double_quotes
-
-Style/StringLiteralsInInterpolation:
- EnforcedStyle: double_quotes
CHANGELOG.md
@@ -1,5 +1,54 @@
## [Unreleased]
+## [0.3.0] - 2025-11-05
+
+### Added
+- **Mode System**: Control agent capabilities with workflow modes
+ - `/mode plan` - Read-only mode (grep, list, read)
+ - `/mode build` - Read + Write mode (grep, list, read, patch, write)
+ - `/mode verify` - Read + Execute mode (grep, list, read, execute)
+ - `/mode auto` - All tools enabled
+ - Each mode adapts system prompt to guide appropriate behavior
+- Improved output formatting
+ - Suppressed verbose thinking/reasoning output
+ - Clean tool call display (e.g., `date` instead of full JSON hash)
+ - Mode switch confirmation messages
+ - Clear command feedback
+- Design philosophy documentation in README
+- Mode system documentation
+
+### Changed
+- **BREAKING**: Removed `llm-ollama` and `llm-openai` standalone executables (use main `elelem chat` command)
+- **BREAKING**: Simplified architecture - consolidated all logic into Agent class
+ - Removed Configuration class
+ - Removed Toolbox system
+ - Removed MCP client infrastructure
+ - Removed Tool and Tools classes
+ - Removed TUI abstraction layer (direct puts/Reline usage)
+ - Removed API wrapper class
+ - Removed state machine
+- Improved execute tool description to guide LLM toward direct command execution
+- Extracted tool definitions from long inline strings to readable private methods
+- Updated README with clear philosophy and usage examples
+- Reduced total codebase from 417 to 395 lines (-5%)
+
+### Fixed
+- Working directory handling for execute tool (handles empty string cwd)
+- REPL EOF handling (graceful exit when input stream ends)
+- Tool call formatting now shows clean, readable commands
+
+### Removed
+- `exe/llm-ollama` (359 lines)
+- `exe/llm-openai` (340 lines)
+- `lib/elelem/configuration.rb`
+- `lib/elelem/toolbox.rb` and toolbox/* files
+- `lib/elelem/mcp_client.rb`
+- `lib/elelem/tool.rb` and `lib/elelem/tools.rb`
+- `lib/elelem/tui.rb`
+- `lib/elelem/api.rb`
+- `lib/elelem/states/*` (state machine infrastructure)
+- Removed ~750 lines of unused/redundant code
+
## [0.2.1] - 2025-10-15
### Fixed
elelem.gemspec
@@ -33,41 +33,17 @@ Gem::Specification.new do |spec|
"README.md",
"Rakefile",
"exe/elelem",
- "exe/llm-ollama",
- "exe/llm-openai",
"lib/elelem.rb",
"lib/elelem/agent.rb",
- "lib/elelem/api.rb",
"lib/elelem/application.rb",
- "lib/elelem/configuration.rb",
"lib/elelem/conversation.rb",
- "lib/elelem/mcp_client.rb",
- "lib/elelem/states/idle.rb",
- "lib/elelem/states/working.rb",
- "lib/elelem/states/working/error.rb",
- "lib/elelem/states/working/executing.rb",
- "lib/elelem/states/working/state.rb",
- "lib/elelem/states/working/talking.rb",
- "lib/elelem/states/working/thinking.rb",
- "lib/elelem/states/working/waiting.rb",
"lib/elelem/system_prompt.erb",
- "lib/elelem/tool.rb",
- "lib/elelem/toolbox.rb",
- "lib/elelem/toolbox/exec.rb",
- "lib/elelem/toolbox/file.rb",
- "lib/elelem/toolbox/mcp.rb",
- "lib/elelem/toolbox/memory.rb",
- "lib/elelem/toolbox/prompt.rb",
- "lib/elelem/toolbox/web.rb",
- "lib/elelem/tools.rb",
- "lib/elelem/tui.rb",
"lib/elelem/version.rb",
]
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
- spec.add_dependency "cli-ui"
spec.add_dependency "erb"
spec.add_dependency "json"
spec.add_dependency "json-schema"
Gemfile
@@ -8,4 +8,3 @@ gemspec
gem "irb"
gem "rake", "~> 13.0"
gem "rspec", "~> 3.0"
-gem "rubocop", "~> 1.21"
Gemfile.lock
@@ -1,8 +1,7 @@
PATH
remote: .
specs:
- elelem (0.2.1)
- cli-ui
+ elelem (0.3.0)
erb
json
json-schema
@@ -18,10 +17,8 @@ GEM
specs:
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
- ast (2.4.3)
base64 (0.3.0)
bigdecimal (3.2.2)
- cli-ui (2.4.0)
date (3.4.1)
diff-lcs (1.6.2)
erb (5.0.2)
@@ -34,8 +31,6 @@ GEM
json-schema (6.0.0)
addressable (~> 2.8)
bigdecimal (~> 3.1)
- language_server-protocol (3.17.0.5)
- lint_roller (1.1.0)
logger (1.7.0)
net-hippie (1.4.0)
base64 (~> 0.1)
@@ -51,25 +46,17 @@ GEM
uri (~> 1.0)
open3 (0.2.1)
openssl (3.3.1)
- parallel (1.27.0)
- parser (3.3.9.0)
- ast (~> 2.4.1)
- racc
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
- prism (1.4.0)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
- racc (1.8.1)
- rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
- regexp_parser (2.11.0)
reline (0.6.2)
io-console (~> 0.5)
rspec (3.13.1)
@@ -85,27 +72,9 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.4)
- rubocop (1.79.2)
- json (~> 2.3)
- language_server-protocol (~> 3.17.0.2)
- lint_roller (~> 1.1.0)
- parallel (~> 1.10)
- parser (>= 3.3.0.2)
- rainbow (>= 2.2.2, < 4.0)
- regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.46.0, < 2.0)
- ruby-progressbar (~> 1.7)
- unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.46.0)
- parser (>= 3.3.7.2)
- prism (~> 1.4)
- ruby-progressbar (1.13.0)
stringio (3.1.7)
thor (1.3.2)
timeout (0.4.3)
- unicode-display_width (3.1.4)
- unicode-emoji (~> 4.0, >= 4.0.4)
- unicode-emoji (4.0.4)
uri (1.0.3)
PLATFORMS
@@ -117,7 +86,6 @@ DEPENDENCIES
irb
rake (~> 13.0)
rspec (~> 3.0)
- rubocop (~> 1.21)
BUNDLED WITH
2.7.2
Rakefile
@@ -2,9 +2,7 @@
require "bundler/gem_tasks"
require "rspec/core/rake_task"
-require "rubocop/rake_task"
RSpec::Core::RakeTask.new(:spec)
-RuboCop::RakeTask.new
-task default: %i[spec rubocop]
+task default: %i[spec]
README.md
@@ -1,6 +1,64 @@
# Elelem
-Elelem is an interactive REPL (Read-Eval-Print Loop) for Ollama that provides a command-line chat interface for communicating with AI models. It features tool calling capabilities, streaming responses, and a clean state machine architecture.
+Fast, correct, autonomous - Pick two
+
+PURPOSE:
+
+Elelem is a minimal coding agent written in Ruby. It is intended to
+assist me (a software engineer and computer science student) with writing,
+editing, and managing code and text files from the command line. It acts
+as a direct interface to an LLM, providing it with a simple text-based
+UI and access to the local filesystem.
+
+DESIGN PRINCIPLES:
+
+- Follows the Unix philosophy: simple, composable, minimal.
+- Convention over configuration.
+- Avoids unnecessary defensive checks, or complexity.
+- Assumes a mature and responsible LLM that behaves like a capable engineer.
+- Designed for my workflow and preferences.
+- Efficient and minimal like aider - https://aider.chat/
+- UX like Claude Code - https://docs.claude.com/en/docs/claude-code/overview
+
+SYSTEM ASSUMPTIONS:
+
+- This script is used on a Linux system with the following tools: Alacritty, tmux, Bash, and Vim.
+- It is always run inside a Git repository.
+- All project work is assumed to be version-controlled with Git.
+- Git is expected to be available and working; no checks are necessary.
+
+SCOPE:
+
+- This program operates only on code and plain-text files.
+- It does not need to support binary files.
+- The LLM has full access to execute system commands.
+- There are no sandboxing, permission, or validation layers.
+- Execution is not restricted or monitored - responsibility is delegated to the LLM.
+
+CONFIGURATION:
+
+- Avoid adding configuration options unless absolutely necessary.
+- Prefer hard-coded values that can be changed later if needed.
+- Only introduce environment variables after repeated usage proves them worthwhile.
+
+UI EXPECTATIONS:
+
+- The TUI must remain simple, fast, and predictable.
+- No mouse support or complex UI components are required.
+- Interaction is strictly keyboard-driven.
+
+CODING STANDARDS FOR LLM:
+
+- Do not add error handling or logging unless it is essential for functionality.
+- Keep methods short and single-purpose.
+- Use descriptive, conventional names.
+- Stick to Ruby's standard library whenever possible.
+
+HELPFUL LINKS:
+
+- https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
+- https://www.anthropic.com/engineering/writing-tools-for-agents
+- https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
## Installation
@@ -29,7 +87,6 @@ elelem chat
- `--host`: Specify Ollama host (default: localhost:11434)
- `--model`: Specify Ollama model (default: gpt-oss, currently only tested with gpt-oss)
- `--token`: Provide authentication token
-- `--debug`: Enable debug logging
### Examples
@@ -39,29 +96,35 @@ elelem chat
# Chat with specific model and host
elelem chat --model llama2 --host remote-host:11434
-
-# Enable debug mode
-elelem chat --debug
```
### Features
- **Interactive REPL**: Clean command-line interface for chatting
-- **Tool Execution**: Execute shell commands when requested by the AI
+- **Mode System**: Control agent capabilities with workflow modes (plan, build, verify, auto)
+- **Tool Execution**: Execute shell commands, read/write files, search code
- **Streaming Responses**: Real-time streaming of AI responses
-- **State Machine**: Robust state management for different interaction modes
- **Conversation History**: Maintains context across the session
+### Mode System
+
+Control what tools the agent can access:
+
+```bash
+/mode plan # Read-only (grep, list, read)
+/mode build # Read + Write (grep, list, read, patch, write)
+/mode verify # Read + Execute (grep, list, read, execute)
+/mode auto # All tools enabled
+```
+
+Each mode adapts the system prompt to guide appropriate behavior.
+
## Development
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
-## Contributing
-
-Bug reports and pull requests are welcome on GitHub at https://github.com/xlgmokha/elelem.
-
## License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).