Commit d7ba7b4
Changed files (14)
exe
lib
elelem
spec
elelem
server
exe/elelem-server
@@ -0,0 +1,17 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "elelem/server"
+require "optparse"
+
+provider = ENV.fetch("ELELEM_PROVIDER", "stub")
+port = 4567
+
+OptionParser.new do |o|
+ o.banner = "Usage: elelem-server [options]"
+ o.on("-p", "--provider NAME", "Provider to use (default: #{provider})") { |v| provider = v }
+ o.on("--port PORT", Integer, "Port to listen on (default: #{port})") { |v| port = v }
+ o.on("-h", "--help", "Show this help") { puts o; exit }
+end.parse!(ARGV)
+
+Elelem::Server.serve(provider: provider, port: port)
lib/elelem/server/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Server
+ VERSION = "0.1.0"
+ end
+end
lib/elelem/server/web_terminal.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Server
+ class WebTerminal
+ def initialize
+ @subscribers = []
+ @mutex = Mutex.new
+ @answers = Queue.new
+ end
+
+ def subscribe
+ Queue.new.tap { |queue| @mutex.synchronize { @subscribers << queue } }
+ end
+
+ def unsubscribe(queue)
+ @mutex.synchronize { @subscribers.delete(queue) }
+ end
+
+ def interactive?
+ true
+ end
+
+ def quiet?
+ false
+ end
+
+ def ask(prompt)
+ emit("prompt", prompt)
+ @answers.pop
+ end
+
+ def think(text)
+ emit("thinking", text)
+ nil
+ end
+
+ def say(text, markdown: false)
+ emit("content", text)
+ end
+
+ def do(header)
+ gap
+ say header
+ end
+
+ def oops(error)
+ say "\n ✗ #{error.message}"
+ Elelem.logger.warn("agent: #{error.message}\n#{error.backtrace.join("\n")}")
+ end
+
+ def print(text)
+ emit("content", text)
+ end
+
+ def markdown(text)
+ text
+ end
+
+ def display_file(path, fallback: nil)
+ content = File.read(path)
+ raise ArgumentError unless content.valid_encoding?
+
+ say(content)
+ rescue Errno::ENOENT, Errno::EISDIR, ArgumentError
+ say(fallback || path)
+ end
+
+ def answer(text)
+ @answers << text
+ end
+
+ def done
+ broadcast(type: "done")
+ end
+
+ def waiting; end
+
+ def gap; end
+
+ def newline(n: 1); end
+
+ private
+
+ def emit(type, text)
+ plain = text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
+ return if plain.strip.empty?
+
+ broadcast(type: type, text: plain)
+ end
+
+ def broadcast(event)
+ @mutex.synchronize { @subscribers.each { |queue| queue << event } }
+ end
+ end
+ end
+end
lib/elelem/server.rb
@@ -1,7 +1,21 @@
# frozen_string_literal: true
+require "elelem"
+require "json"
+require "webrick"
+
+require_relative "server/version"
+require_relative "server/web_terminal"
+
module Elelem
class Server
+ def self.serve(provider: "stub", port: 4567, toolbox: Toolbox.new)
+ client = Providers.build(provider)
+ agent = Agent.new(client, toolbox: toolbox, terminal: WebTerminal.new)
+ Plugins.setup!(agent)
+ new(agent, port: port).start
+ end
+
def initialize(agent, port: 4567, address: "0.0.0.0")
@agent = agent
@port = port
lib/elelem/web_terminal.rb
@@ -1,90 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class WebTerminal
- def initialize
- @subscribers = []
- @mutex = Mutex.new
- @answers = Queue.new
- end
-
- def subscribe
- Queue.new.tap { |queue| @mutex.synchronize { @subscribers << queue } }
- end
-
- def unsubscribe(queue)
- @mutex.synchronize { @subscribers.delete(queue) }
- end
-
- def interactive?
- true
- end
-
- def quiet?
- false
- end
-
- def ask(prompt)
- emit("prompt", prompt)
- @answers.pop
- end
-
- def think(text)
- emit("thinking", text)
- nil
- end
-
- def say(text)
- emit("content", text)
- end
-
- def do(header)
- gap
- say header
- end
-
- def oops(error)
- say "\n ✗ #{error.message}"
- Elelem.logger.warn("agent: #{error.message}\n#{error.backtrace.join("\n")}")
- end
-
- def print(text)
- emit("content", text)
- end
-
- def markdown(text)
- text
- end
-
- def display_file(path, fallback: nil)
- say(fallback || path)
- end
-
- def answer(text)
- @answers << text
- end
-
- def done
- broadcast(type: "done")
- end
-
- def waiting; end
-
- def gap; end
-
- def newline(n: 1); end
-
- private
-
- def emit(type, text)
- plain = text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
- return if plain.strip.empty?
-
- broadcast(type: type, text: plain)
- end
-
- def broadcast(event)
- @mutex.synchronize { @subscribers.each { |queue| queue << event } }
- end
- end
-end
spec/elelem/web_terminal_spec.rb → spec/elelem/server/web_terminal_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.describe Elelem::WebTerminal do
+RSpec.describe Elelem::Server::WebTerminal do
subject { described_class.new }
let(:events) { subject.subscribe }
@@ -35,6 +35,11 @@ RSpec.describe Elelem::WebTerminal do
subject.say("+ \e[36mexecute\e[0m(...)")
expect(drain).to eq([{ type: "content", text: "+ execute(...)" }])
end
+
+ it "accepts the markdown keyword like Terminal#say" do
+ subject.say("hello", markdown: true)
+ expect(drain).to eq([{ type: "content", text: "hello" }])
+ end
end
describe "#think" do
@@ -52,10 +57,29 @@ RSpec.describe Elelem::WebTerminal do
end
describe "#display_file" do
- it "emits the fallback" do
- subject.display_file("a.rb", fallback: "contents")
+ it "emits the file's contents" do
+ Tempfile.create do |file|
+ file.write("contents")
+ file.flush
+ subject.display_file(file.path, fallback: "fallback")
+ expect(drain).to eq([{ type: "content", text: "contents" }])
+ end
+ end
+
+ it "emits the fallback when the file is missing" do
+ subject.display_file("/no/such/file.rb", fallback: "contents")
expect(drain).to eq([{ type: "content", text: "contents" }])
end
+
+ it "emits the fallback when the file is not valid UTF-8" do
+ Tempfile.create do |file|
+ file.binmode
+ file.write("\xFF\xFE\x00\x01")
+ file.flush
+ subject.display_file(file.path, fallback: "contents")
+ expect(drain).to eq([{ type: "content", text: "contents" }])
+ end
+ end
end
describe "#done" do
spec/spec_helper.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require "tempfile"
+require_relative "../lib/elelem/server"
+
+RSpec.configure do |config|
+ config.disable_monkey_patching!
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end
.gitignore
@@ -0,0 +1,8 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
.rspec
@@ -0,0 +1,1 @@
+--require spec_helper
elelem-server.gemspec
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require_relative "lib/elelem/server/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "elelem-server"
+ spec.version = Elelem::Server::VERSION
+ spec.authors = ["mo khan"]
+ spec.email = ["mo@mokhan.ca"]
+
+ spec.summary = "A web UI server plugin for elelem."
+ spec.description = "A web UI server plugin for elelem."
+ spec.homepage = "https://src.mokhan.ca/elelem/server"
+ spec.license = "MIT"
+ spec.required_ruby_version = ">= 4.0.0"
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
+ spec.metadata["homepage_uri"] = spec.homepage
+ spec.metadata["source_code_uri"] = spec.homepage
+
+ gemspec = File.basename(__FILE__)
+ spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
+ ls.readlines("\x0", chomp: true).reject do |f|
+ (f == gemspec) || f.start_with?(*%w[bin/ Gemfile .gitignore])
+ end
+ end
+ spec.bindir = "exe"
+ spec.executables = ["elelem-server"]
+ spec.require_paths = ["lib"]
+
+ spec.add_dependency "elelem", "~> 0.11"
+ spec.add_dependency "json", "~> 2.21"
+ spec.add_dependency "webrick", "~> 1.9"
+end
Gemfile
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gemspec name: "elelem-server"
+
+gem "rake", "~> 13.0"
Gemfile.lock
@@ -0,0 +1,121 @@
+PATH
+ remote: .
+ specs:
+ elelem-server (0.1.0)
+ elelem (~> 0.10)
+ json (~> 2.21)
+ webrick (~> 1.9)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ base64 (0.3.0)
+ bigdecimal (4.1.2)
+ date (3.5.1)
+ digest (3.2.1)
+ elelem (0.10.0)
+ base64 (~> 0.1)
+ date (~> 3.0)
+ digest (~> 3.0)
+ erb (~> 6.0)
+ fileutils (~> 1.0)
+ json (~> 2.0)
+ json_schemer (~> 2.0)
+ logger (~> 1.0)
+ net-hippie (~> 1.0)
+ open3 (~> 0.1)
+ optparse (~> 0.1)
+ pathname (~> 0.1)
+ reline (~> 0.6)
+ securerandom (~> 0.1)
+ shellwords (~> 0.2)
+ stringio (~> 3.0)
+ tempfile (~> 0.3)
+ uri (~> 1.0)
+ webrick (~> 1.9)
+ erb (6.0.7)
+ fileutils (1.8.0)
+ hana (1.3.7)
+ io-console (0.9.2)
+ json (2.21.2)
+ json_schemer (2.5.0)
+ bigdecimal
+ hana (~> 1.3)
+ regexp_parser (~> 2.0)
+ simpleidn (~> 0.2)
+ logger (1.7.0)
+ monitor (0.2.0)
+ net-hippie (1.5.1)
+ base64 (~> 0.1)
+ json (~> 2.0)
+ logger (~> 1.0)
+ monitor (~> 0.1)
+ net-http (~> 0.1)
+ openssl (~> 4.0)
+ resolv (~> 0.1)
+ timeout (~> 0.1)
+ net-http (0.9.1)
+ uri (>= 0.11.1)
+ open3 (0.2.1)
+ openssl (4.0.2)
+ optparse (0.8.1)
+ pathname (0.5.0)
+ rake (13.4.2)
+ regexp_parser (2.12.0)
+ reline (0.7.0)
+ io-console (~> 0.5)
+ resolv (0.7.2)
+ securerandom (0.4.1)
+ shellwords (0.2.2)
+ simpleidn (0.3.0)
+ stringio (3.2.0)
+ tempfile (0.3.1)
+ timeout (0.6.1)
+ uri (1.1.1)
+ webrick (1.9.2)
+
+PLATFORMS
+ ruby
+ x86_64-linux
+
+DEPENDENCIES
+ elelem-server!
+ rake (~> 13.0)
+
+CHECKSUMS
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+ bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
+ date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
+ digest (3.2.1) sha256=ab3312b4e272d7d5dc41c564c86a25861a1f34ac5153374199a0b74861395947
+ elelem (0.10.0) sha256=de67f3a28351640da471e6e80e7a1d79e83e293cbc502a9e98c3c609cad2e237
+ elelem-server (0.1.0)
+ erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
+ fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+ hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
+ io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+ json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ monitor (0.2.0) sha256=18698584f161ca6611d73130663990ca7dc439d9d88095226e683035d66f1f0f
+ net-hippie (1.5.1) sha256=76ecfc7d8df7866c3ed4f2e25fe963d87537451a1fb0d318b58eca161127fbcc
+ net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
+ open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952
+ openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2
+ optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+ pathname (0.5.0) sha256=d5a331784f6e1f2fefb31c2ff0b8855aabfb661d807284ead9fa47b883d81623
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+ regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
+ reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+ resolv (0.7.2) sha256=626d044d975ab2daac759bf898416f1b51e2cb8dcd6727c2b5b5b28b97ead2e1
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+ shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94
+ simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
+ stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+ tempfile (0.3.1) sha256=0bb53ab646744e505eb3102147e22ae130a626a15563e882428c1ec973fed76a
+ timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+ webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
+
+BUNDLED WITH
+ 4.0.20
LICENSE.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2025 mo khan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
Rakefile
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+require "bundler/gem_tasks"
+task default: %i[]