Commit a3afd97

mo khan <mo@mokhan.ca>
2025-10-15 23:10:44
feat: split openai and ollama scripts
1 parent 6aac510
Changed files (2)
exe/llm-ollama
@@ -0,0 +1,338 @@
+#!/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"] || "qwen2.5-coder:7b"
+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["message"]
+    turn_context << { role: message["role"], content: message["content"] || "", tool_calls: message["tool_calls"] }.compact
+
+    if message["tool_calls"]
+      message["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
+
+    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::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-simp → exe/llm-openai
File renamed without changes