agent-chat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0ba9e32fb5bf6e586853fb5d14142a62c0c2a0175789b6631c89a9360ab64930
4
+ data.tar.gz: b835b66f3971f0bb0b1d3e8c2b48a98e9ba01643e6830ec51be2b323c76de765
5
+ SHA512:
6
+ metadata.gz: a5d044d24a030feea6980fff875aaee197284d6dfa4433bf0e2626e3ec9d0c1b02f36a9f0e1c44e4bc6ca476ecda1c6664a42a3b765d8be10d879075b5e6089c
7
+ data.tar.gz: 35e19eec78a50c5234d52150af95036be718183500819e8a42fd6df8a411a0300419e880424a31062755f2c716656cf1104e3bb7368998fabb476ec10748a470
data/config.ru ADDED
@@ -0,0 +1,8 @@
1
+ require 'agent_chat'
2
+ require 'tmpdir'
3
+
4
+ tmpdir = Dir.tmpdir
5
+ AgentChat::Web::App.set :room_discovery, AgentChat::Web::RoomDiscoveryService.new(tmpdir: tmpdir)
6
+ AgentChat::Web::App.set :service_factory, AgentChat::Web::ServiceFactory.new(tmpdir: tmpdir)
7
+
8
+ run AgentChat::Web::App
data/exe/agent-chat ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require 'agent_chat'
3
+
4
+ AgentChat::CLIAdapter.setup($stdin, $stdout, ARGV).run(ARGV)
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+ require 'agent_chat'
3
+ require 'tmpdir'
4
+
5
+ tmpdir = Dir.tmpdir
6
+ AgentChat::Web::App.set :room_discovery, AgentChat::Web::RoomDiscoveryService.new(tmpdir: tmpdir)
7
+ AgentChat::Web::App.set :service_factory, AgentChat::Web::ServiceFactory.new(tmpdir: tmpdir)
8
+
9
+ Thread.new do
10
+ sleep 1
11
+ url = 'http://localhost:4567/index.html'
12
+ case RUBY_PLATFORM
13
+ when /darwin/
14
+ system('open', url)
15
+ when /linux/
16
+ system('xdg-open', url)
17
+ when /mswin|mingw/
18
+ system('start', url)
19
+ end
20
+ end
21
+
22
+ AgentChat::Web::App.run!
@@ -0,0 +1,19 @@
1
+ module AgentChat
2
+ class ArgumentParser
3
+ FLAGS = [:room, :author, :consumer]
4
+
5
+ def self.parse(args)
6
+ return { action: :help } if args.empty? || args.include?('--help') || args.include?('-h')
7
+
8
+ flags = FLAGS.map { |flag| [flag, extract_flag(args, "--#{flag}")] }.to_h.compact
9
+ { action: args[0].to_sym }.merge(flags)
10
+ end
11
+
12
+ def self.extract_flag(args, flag)
13
+ index = args.index(flag)
14
+ index ? args[index + 1] : nil
15
+ end
16
+
17
+ private_class_method :extract_flag
18
+ end
19
+ end
@@ -0,0 +1,80 @@
1
+ require_relative 'argument_parser'
2
+ require_relative 'service/main'
3
+ require_relative 'message_formatter'
4
+
5
+ module AgentChat
6
+ class CLIAdapter
7
+ HELP_TEXT = <<~HELP
8
+ agent-chat - A simple chat messaging tool for inter-agent communication
9
+
10
+ Usage:
11
+ agent-chat <command> [options]
12
+
13
+ Commands:
14
+ send Send a message to a chat room (reads content from stdin)
15
+ receive Receive messages from a chat room (one-shot)
16
+ stream Stream messages from a chat room (continuous, Ctrl-C to stop)
17
+
18
+ Options:
19
+ --room <name> Chat room name (required)
20
+ --author <name> Author name for sending messages
21
+ --consumer <name> Consumer name for tracking read position
22
+ -h, --help Show this help message
23
+
24
+ Examples:
25
+ echo "Hello!" | agent-chat send --room general --author Alice
26
+ agent-chat receive --room general --consumer Bob
27
+ agent-chat stream --room general --consumer Bob
28
+ HELP
29
+
30
+ def self.setup(stdin, stdout, args, service: nil, formatter: AgentChat::MessageFormatter)
31
+ parsed = AgentChat::ArgumentParser.parse(args)
32
+ return new(stdin:, stdout:, service: nil, formatter: nil) if parsed[:action] == :help
33
+
34
+ service ||= AgentChat::Service::Main.standard(room: parsed[:room])
35
+ new(stdin:, stdout:, service:, formatter:)
36
+ end
37
+
38
+ def initialize(stdin:, stdout:, service:, formatter: nil)
39
+ @stdin = stdin
40
+ @stdout = stdout
41
+ @service = service
42
+ @formatter = formatter
43
+ end
44
+
45
+ def run(args)
46
+ parsed = AgentChat::ArgumentParser.parse(args)
47
+ dispatch(parsed)
48
+ rescue Interrupt
49
+ # exit gracefully
50
+ end
51
+
52
+ private
53
+
54
+ def dispatch(parsed)
55
+ case parsed[:action]
56
+ when :help then @stdout.puts HELP_TEXT
57
+ when :send then send_message(parsed)
58
+ when :receive then receive_messages(parsed)
59
+ when :stream then stream_messages(parsed)
60
+ end
61
+ end
62
+
63
+ def send_message(parsed)
64
+ @service.send_message(room: parsed[:room], author: parsed[:author], content: @stdin.read)
65
+ end
66
+
67
+ def receive_messages(parsed)
68
+ messages = @service.get_new_messages(room: parsed[:room], consumer: parsed[:consumer])
69
+ @stdout.puts @formatter.format(messages)
70
+ end
71
+
72
+ def stream_messages(parsed)
73
+ loop do
74
+ messages = @service.get_new_messages(room: parsed[:room], consumer: parsed[:consumer])
75
+ @stdout.puts @formatter.format(messages) if messages.any?
76
+ sleep 1
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,15 @@
1
+ module AgentChat
2
+ class Message
3
+ attr_reader :author, :content, :timestamp
4
+
5
+ def initialize(author:, content:, timestamp: Time.now)
6
+ @author = author
7
+ @content = content
8
+ @timestamp = timestamp
9
+ end
10
+
11
+ def to_h
12
+ { author: @author, content: @content, timestamp: @timestamp }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module AgentChat
2
+ class MessageFormatter
3
+ def self.format(messages)
4
+ messages.map { |message| format_single(message) }.join("\n\n\n\n")
5
+ end
6
+
7
+ def self.format_single(message)
8
+ timestamp = message.timestamp.strftime('%Y-%m-%d %H:%M:%S')
9
+ "<<< #{message.author} | #{timestamp} >>>\n#{message.content}"
10
+ end
11
+
12
+ private_class_method :format_single
13
+ end
14
+ end
@@ -0,0 +1,110 @@
1
+ require 'sqlite3'
2
+ require 'fileutils'
3
+
4
+ module AgentChat
5
+ module Persistence
6
+ class Database
7
+ def initialize(file_resolver:)
8
+ db_path = file_resolver.db_location
9
+ FileUtils.mkdir_p(File.dirname(db_path))
10
+ @db = SQLite3::Database.new(db_path)
11
+ create_tables
12
+ end
13
+
14
+ def find_or_create_room(name)
15
+ @db.execute("INSERT OR IGNORE INTO rooms (name) VALUES (?)", [name])
16
+ @db.get_first_value("SELECT id FROM rooms WHERE name = ?", [name])
17
+ end
18
+
19
+ def find_or_create_consumer(nickname)
20
+ @db.execute("INSERT OR IGNORE INTO consumers (nickname) VALUES (?)", [nickname])
21
+ @db.get_first_value("SELECT id FROM consumers WHERE nickname = ?", [nickname])
22
+ end
23
+
24
+ def insert_message(room_id:, author:, content:, timestamp:)
25
+ @db.execute(
26
+ "INSERT INTO messages (room_id, author, content, timestamp) VALUES (?, ?, ?, ?)",
27
+ [room_id, author, content, timestamp]
28
+ )
29
+ end
30
+
31
+ def messages_since(room_id:, since_id:)
32
+ @db.execute(<<~SQL, [room_id, since_id])
33
+ SELECT id, author, content, timestamp
34
+ FROM messages
35
+ WHERE room_id = ? AND id > ?
36
+ ORDER BY id
37
+ SQL
38
+ end
39
+
40
+ def get_last_read_message_id(consumer_id:, room_id:)
41
+ @db.get_first_value(
42
+ "SELECT last_read_message_id FROM read_positions WHERE consumer_id = ? AND room_id = ?",
43
+ [consumer_id, room_id]
44
+ ) || 0
45
+ end
46
+
47
+ def update_last_read_message_id(consumer_id:, room_id:, message_id:)
48
+ @db.execute(<<~SQL, [message_id, consumer_id, room_id])
49
+ INSERT INTO read_positions (last_read_message_id, consumer_id, room_id)
50
+ VALUES (?, ?, ?)
51
+ ON CONFLICT(consumer_id, room_id) DO UPDATE SET last_read_message_id = excluded.last_read_message_id
52
+ SQL
53
+ end
54
+
55
+ def all_rooms
56
+ @db.execute("SELECT name FROM rooms ORDER BY name").map { |row| row[0] }
57
+ end
58
+
59
+ private
60
+
61
+ def create_tables
62
+ create_rooms_table
63
+ create_consumers_table
64
+ create_messages_table
65
+ create_read_positions_table
66
+ end
67
+
68
+ def create_rooms_table
69
+ @db.execute(<<~SQL)
70
+ CREATE TABLE IF NOT EXISTS rooms (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ name TEXT NOT NULL UNIQUE
73
+ )
74
+ SQL
75
+ end
76
+
77
+ def create_consumers_table
78
+ @db.execute(<<~SQL)
79
+ CREATE TABLE IF NOT EXISTS consumers (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ nickname TEXT NOT NULL UNIQUE
82
+ )
83
+ SQL
84
+ end
85
+
86
+ def create_messages_table
87
+ @db.execute(<<~SQL)
88
+ CREATE TABLE IF NOT EXISTS messages (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
91
+ author TEXT NOT NULL,
92
+ content TEXT NOT NULL,
93
+ timestamp TEXT NOT NULL
94
+ )
95
+ SQL
96
+ end
97
+
98
+ def create_read_positions_table
99
+ @db.execute(<<~SQL)
100
+ CREATE TABLE IF NOT EXISTS read_positions (
101
+ consumer_id INTEGER NOT NULL REFERENCES consumers(id),
102
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
103
+ last_read_message_id INTEGER NOT NULL,
104
+ PRIMARY KEY (consumer_id, room_id)
105
+ )
106
+ SQL
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,14 @@
1
+ module AgentChat
2
+ module Persistence
3
+ class FileResolver
4
+ def initialize(tmp_dir_root:, room:)
5
+ @tmp_dir_root = tmp_dir_root
6
+ @room = room
7
+ end
8
+
9
+ def db_location
10
+ "#{@tmp_dir_root}/agent-chat/rooms/#{@room}/room.db"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ require 'time'
2
+
3
+ require_relative 'persistence/file_resolver'
4
+ require_relative 'persistence/database'
5
+ require_relative 'message'
6
+
7
+ module AgentChat
8
+ module Persistence
9
+ def self.rows_to_messages(rows)
10
+ rows.map do |row|
11
+ AgentChat::Message.new(
12
+ author: row[1],
13
+ content: row[2],
14
+ timestamp: Time.parse(row[3])
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,70 @@
1
+ require_relative '../persistence'
2
+ require 'tmpdir'
3
+
4
+ module AgentChat
5
+ module Service
6
+ class Main
7
+ def self.standard(tmpdir: Dir.tmpdir, room:)
8
+ raise ArgumentError, "room is required" if room.nil? || room.to_s.strip.empty?
9
+
10
+ file_resolver = AgentChat::Persistence::FileResolver.new(tmp_dir_root: tmpdir, room:)
11
+ database = AgentChat::Persistence::Database.new(file_resolver:)
12
+ new(database:)
13
+ end
14
+
15
+ def initialize(database:)
16
+ @database = database
17
+ end
18
+
19
+ def send_message(room:, author:, content:)
20
+ room_id = @database.find_or_create_room(room)
21
+ @database.insert_message(
22
+ room_id: room_id,
23
+ author: author,
24
+ content: content,
25
+ timestamp: Time.now.iso8601
26
+ )
27
+ end
28
+
29
+ def list_rooms
30
+ @database.all_rooms
31
+ end
32
+
33
+ def get_messages(room:, consumer: nil)
34
+ room_id = @database.find_or_create_room(room)
35
+ rows = @database.messages_since(room_id: room_id, since_id: 0)
36
+ register_read_position(room_id:, consumer:, rows:) if consumer
37
+ AgentChat::Persistence.rows_to_messages(rows)
38
+ end
39
+
40
+ def get_new_messages(room:, consumer:)
41
+ room_id = @database.find_or_create_room(room)
42
+ consumer_id = @database.find_or_create_consumer(consumer)
43
+ last_read_id = @database.get_last_read_message_id(consumer_id: consumer_id, room_id: room_id)
44
+
45
+ rows = @database.messages_since(room_id: room_id, since_id: last_read_id)
46
+ update_read_position(consumer_id:, room_id:, rows:)
47
+ AgentChat::Persistence.rows_to_messages(rows)
48
+ end
49
+
50
+ private
51
+
52
+ def register_read_position(room_id:, consumer:, rows:)
53
+ return unless rows.any?
54
+
55
+ consumer_id = @database.find_or_create_consumer(consumer)
56
+ update_read_position(consumer_id:, room_id:, rows:)
57
+ end
58
+
59
+ def update_read_position(consumer_id:, room_id:, rows:)
60
+ return unless rows.any?
61
+
62
+ @database.update_last_read_message_id(
63
+ consumer_id: consumer_id,
64
+ room_id: room_id,
65
+ message_id: rows.last[0]
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,3 @@
1
+ module AgentChat
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,39 @@
1
+ require 'sinatra/base'
2
+ require 'sinatra/json'
3
+
4
+ module AgentChat
5
+ module Web
6
+ class App < Sinatra::Base
7
+ helpers Sinatra::JSON
8
+
9
+ set :public_folder, File.expand_path('../../../../public', __FILE__)
10
+
11
+ get '/api/rooms' do
12
+ json settings.room_discovery.list_rooms
13
+ end
14
+
15
+ get '/api/rooms/:room/messages' do
16
+ service = settings.service_factory.for_room(params[:room])
17
+ messages = service.get_messages(room: params[:room], consumer: params[:consumer])
18
+ json messages.map { |m| { author: m.author, content: m.content, timestamp: m.timestamp } }
19
+ end
20
+
21
+ get '/api/rooms/:room/messages/new' do
22
+ service = settings.service_factory.for_room(params[:room])
23
+ messages = service.get_new_messages(room: params[:room], consumer: params[:consumer])
24
+ json messages.map { |m| { author: m.author, content: m.content, timestamp: m.timestamp } }
25
+ end
26
+
27
+ post '/api/rooms/:room/messages' do
28
+ service = settings.service_factory.for_room(params[:room])
29
+ data = JSON.parse(request.body.read)
30
+ service.send_message(
31
+ room: params[:room],
32
+ author: data['author'],
33
+ content: data['content']
34
+ )
35
+ status 201
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,18 @@
1
+ module AgentChat
2
+ module Web
3
+ class RoomDiscoveryService
4
+ def initialize(tmpdir:)
5
+ @tmpdir = tmpdir
6
+ end
7
+
8
+ def list_rooms
9
+ rooms_path = "#{@tmpdir}/agent-chat/rooms"
10
+ return [] unless Dir.exist?(rooms_path)
11
+
12
+ Dir.children(rooms_path).select do |name|
13
+ File.exist?("#{rooms_path}/#{name}/room.db")
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ module AgentChat
2
+ module Web
3
+ class ServiceFactory
4
+ def initialize(tmpdir:)
5
+ @tmpdir = tmpdir
6
+ end
7
+
8
+ def for_room(room)
9
+ AgentChat::Service::Main.standard(tmpdir: @tmpdir, room: room)
10
+ end
11
+ end
12
+ end
13
+ end
data/lib/agent_chat.rb ADDED
@@ -0,0 +1,10 @@
1
+ require_relative 'agent_chat/version'
2
+ require_relative 'agent_chat/message'
3
+ require_relative 'agent_chat/message_formatter'
4
+ require_relative 'agent_chat/argument_parser'
5
+ require_relative 'agent_chat/persistence'
6
+ require_relative 'agent_chat/cli_adapter'
7
+ require_relative 'agent_chat/service/main'
8
+ require_relative 'agent_chat/web/room_discovery_service'
9
+ require_relative 'agent_chat/web/service_factory'
10
+ require_relative 'agent_chat/web/app'
@@ -0,0 +1,87 @@
1
+ #message-form {
2
+ padding: 1.5rem;
3
+ background: #16213e;
4
+ border-top: 1px solid #0f3460;
5
+ }
6
+
7
+ #message-form .form-row {
8
+ display: flex;
9
+ gap: 0.75rem;
10
+ align-items: flex-end;
11
+ }
12
+
13
+ #message-form .author-wrapper {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: 0.5rem;
17
+ margin-bottom: 0.75rem;
18
+ }
19
+
20
+ #message-form .author-wrapper label {
21
+ font-size: 0.8rem;
22
+ color: #888;
23
+ }
24
+
25
+ #message-author {
26
+ background: transparent;
27
+ border: none;
28
+ border-bottom: 1px solid #0f3460;
29
+ color: #e94560;
30
+ font-size: 0.85rem;
31
+ font-weight: 500;
32
+ padding: 0.25rem 0;
33
+ width: 150px;
34
+ }
35
+
36
+ #message-author:focus {
37
+ outline: none;
38
+ border-bottom-color: #e94560;
39
+ }
40
+
41
+ #message-content {
42
+ flex: 1;
43
+ min-height: 60px;
44
+ max-height: 200px;
45
+ padding: 1rem;
46
+ border: 1px solid #0f3460;
47
+ border-radius: 12px;
48
+ background: #1a1a2e;
49
+ color: #eee;
50
+ font-size: 1rem;
51
+ font-family: inherit;
52
+ resize: none;
53
+ line-height: 1.5;
54
+ }
55
+
56
+ #message-content:focus {
57
+ outline: none;
58
+ border-color: #e94560;
59
+ box-shadow: 0 0 0 2px rgba(233, 69, 96, 0.15);
60
+ }
61
+
62
+ #message-content::placeholder {
63
+ color: #666;
64
+ }
65
+
66
+ #message-form button {
67
+ padding: 1rem 1.5rem;
68
+ background: #e94560;
69
+ color: white;
70
+ border: none;
71
+ border-radius: 12px;
72
+ font-size: 1rem;
73
+ font-weight: 500;
74
+ cursor: pointer;
75
+ transition: all 0.2s;
76
+ align-self: stretch;
77
+ min-height: 60px;
78
+ }
79
+
80
+ #message-form button:hover {
81
+ background: #d63a55;
82
+ transform: translateY(-1px);
83
+ }
84
+
85
+ #message-form button:active {
86
+ transform: translateY(0);
87
+ }
@@ -0,0 +1,76 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ margin: 0;
4
+ padding: 0;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
9
+ background: #1a1a2e;
10
+ color: #eee;
11
+ height: 100vh;
12
+ }
13
+
14
+ .container {
15
+ display: flex;
16
+ height: 100vh;
17
+ }
18
+
19
+ .sidebar {
20
+ width: 240px;
21
+ background: #16213e;
22
+ padding: 1rem;
23
+ border-right: 1px solid #0f3460;
24
+ }
25
+
26
+ .sidebar h1 {
27
+ font-size: 1.2rem;
28
+ color: #e94560;
29
+ margin-bottom: 1rem;
30
+ padding-bottom: 0.5rem;
31
+ border-bottom: 1px solid #0f3460;
32
+ }
33
+
34
+ #rooms-list {
35
+ list-style: none;
36
+ }
37
+
38
+ #rooms-list li {
39
+ padding: 0.75rem 1rem;
40
+ margin: 0.25rem 0;
41
+ border-radius: 6px;
42
+ cursor: pointer;
43
+ transition: background 0.2s;
44
+ }
45
+
46
+ #rooms-list li:hover {
47
+ background: #0f3460;
48
+ }
49
+
50
+ #rooms-list li.active {
51
+ background: #e94560;
52
+ color: white;
53
+ }
54
+
55
+ .chat {
56
+ flex: 1;
57
+ display: flex;
58
+ flex-direction: column;
59
+ }
60
+
61
+ #room-header {
62
+ padding: 1rem 1.5rem;
63
+ background: #16213e;
64
+ border-bottom: 1px solid #0f3460;
65
+ }
66
+
67
+ #room-header h2 {
68
+ font-size: 1.1rem;
69
+ font-weight: 500;
70
+ }
71
+
72
+ #messages {
73
+ flex: 1;
74
+ overflow-y: auto;
75
+ padding: 1rem 1.5rem;
76
+ }
@@ -0,0 +1,75 @@
1
+ @import url('layout.css');
2
+ @import url('form.css');
3
+
4
+ .message {
5
+ margin-bottom: 1.5rem;
6
+ padding: 1rem;
7
+ background: #16213e;
8
+ border-radius: 8px;
9
+ border-left: 3px solid #e94560;
10
+ }
11
+
12
+ .message-author {
13
+ font-weight: 600;
14
+ color: #e94560;
15
+ margin-bottom: 0.5rem;
16
+ font-size: 0.9rem;
17
+ }
18
+
19
+ .message-content {
20
+ line-height: 1.6;
21
+ }
22
+
23
+ .message-content p {
24
+ margin-bottom: 0.75rem;
25
+ }
26
+
27
+ .message-content p:last-child {
28
+ margin-bottom: 0;
29
+ }
30
+
31
+ .message-content code {
32
+ background: #0f3460;
33
+ padding: 0.2em 0.4em;
34
+ border-radius: 3px;
35
+ font-size: 0.9em;
36
+ }
37
+
38
+ .message-content pre {
39
+ background: #0f3460;
40
+ padding: 1rem;
41
+ border-radius: 6px;
42
+ overflow-x: auto;
43
+ margin: 0.75rem 0;
44
+ }
45
+
46
+ .message-content pre code {
47
+ background: none;
48
+ padding: 0;
49
+ }
50
+
51
+ .message-content ul, .message-content ol {
52
+ margin: 0.75rem 0;
53
+ padding-left: 1.5rem;
54
+ }
55
+
56
+ .message-content blockquote {
57
+ border-left: 3px solid #e94560;
58
+ padding-left: 1rem;
59
+ margin: 0.75rem 0;
60
+ color: #aaa;
61
+ }
62
+
63
+ .message-content a {
64
+ color: #e94560;
65
+ }
66
+
67
+ .message-content h1, .message-content h2, .message-content h3 {
68
+ margin: 1rem 0 0.5rem;
69
+ }
70
+
71
+ .message-content h1:first-child,
72
+ .message-content h2:first-child,
73
+ .message-content h3:first-child {
74
+ margin-top: 0;
75
+ }
data/public/index.html ADDED
@@ -0,0 +1,91 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Agent Chat</title>
7
+ <link rel="stylesheet" href="/css/style.css">
8
+ <script type="importmap">
9
+ {
10
+ "imports": {
11
+ "marked": "https://esm.sh/marked@15"
12
+ }
13
+ }
14
+ </script>
15
+ </head>
16
+ <body>
17
+ <div class="container">
18
+ <aside class="sidebar">
19
+ <h1>Rooms</h1>
20
+ <ul id="rooms-list"></ul>
21
+ </aside>
22
+
23
+ <main class="chat">
24
+ <header id="room-header">
25
+ <h2 id="room-title">Select a room</h2>
26
+ </header>
27
+ <div id="messages"></div>
28
+ <form id="message-form">
29
+ <div class="author-wrapper">
30
+ <label for="message-author">Posting as</label>
31
+ <input id="message-author" type="text" />
32
+ </div>
33
+ <div class="form-row">
34
+ <textarea id="message-content" placeholder="Type your message..." rows="1"></textarea>
35
+ <button type="submit">Send</button>
36
+ </div>
37
+ </form>
38
+ </main>
39
+ </div>
40
+
41
+ <script type="module">
42
+ import { fetchAndRenderRooms } from './js/rooms.js';
43
+ import { fetchAndRenderMessages, fetchAndAppendNewMessages, getConsumer, postMessage, createKeyDownHandler } from './js/messages.js';
44
+
45
+ let pollingInterval = null;
46
+ let currentRoom = null;
47
+
48
+ async function onRoomSelected(room) {
49
+ currentRoom = room;
50
+
51
+ // Stop previous polling
52
+ if (pollingInterval) clearInterval(pollingInterval);
53
+
54
+ document.getElementById('room-title').textContent = room;
55
+ document.querySelectorAll('#rooms-list li').forEach(li => {
56
+ li.classList.toggle('active', li.textContent === room);
57
+ });
58
+ const consumer = getConsumer();
59
+ const count = await fetchAndRenderMessages(room, consumer);
60
+ document.getElementById('room-title').textContent = `${room} (${count})`;
61
+
62
+ // Pre-populate author from consumer
63
+ document.getElementById('message-author').value = consumer;
64
+
65
+ // Start polling for new messages
66
+ pollingInterval = setInterval(async () => {
67
+ const count = await fetchAndAppendNewMessages(room, consumer);
68
+ document.getElementById('room-title').textContent = `${room} (${count})`;
69
+ }, 5000);
70
+ }
71
+
72
+ const form = document.getElementById('message-form');
73
+ form.addEventListener('submit', async (e) => {
74
+ e.preventDefault();
75
+ if (!currentRoom) return;
76
+
77
+ const author = document.getElementById('message-author').value;
78
+ const content = document.getElementById('message-content').value;
79
+ if (!content.trim()) return;
80
+
81
+ const count = await postMessage(currentRoom, author, content);
82
+ document.getElementById('room-title').textContent = `${currentRoom} (${count})`;
83
+ document.getElementById('message-content').value = '';
84
+ });
85
+
86
+ document.getElementById('message-content').addEventListener('keydown', createKeyDownHandler(form));
87
+
88
+ fetchAndRenderRooms(onRoomSelected);
89
+ </script>
90
+ </body>
91
+ </html>
@@ -0,0 +1,46 @@
1
+ import { marked } from 'marked';
2
+
3
+ export class MessageFeed {
4
+ constructor() {
5
+ this.messages = [];
6
+ }
7
+
8
+ setMessages(messages) {
9
+ this.messages = messages;
10
+ }
11
+
12
+ appendMessages(messages) {
13
+ this.messages = this.messages.filter(m => !m.optimistic);
14
+ this.messages = [...this.messages, ...messages];
15
+ }
16
+
17
+ get count() {
18
+ return this.messages.length;
19
+ }
20
+ }
21
+
22
+ export function renderMessageFeed(feed, container) {
23
+ const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
24
+
25
+ container.innerHTML = '';
26
+ for (const msg of feed.messages) {
27
+ const div = document.createElement('div');
28
+ div.className = 'message';
29
+
30
+ const author = document.createElement('div');
31
+ author.className = 'message-author';
32
+ author.textContent = msg.author;
33
+
34
+ const content = document.createElement('div');
35
+ content.className = 'message-content';
36
+ content.innerHTML = marked.parse(msg.content);
37
+
38
+ div.appendChild(author);
39
+ div.appendChild(content);
40
+ container.appendChild(div);
41
+ }
42
+
43
+ if (wasAtBottom) {
44
+ container.scrollTop = container.scrollHeight;
45
+ }
46
+ }
@@ -0,0 +1,56 @@
1
+ import { MessageFeed, renderMessageFeed } from './message_feed.js';
2
+
3
+ export { MessageFeed } from './message_feed.js';
4
+
5
+ let currentFeed = new MessageFeed();
6
+
7
+ export function getConsumer() {
8
+ const params = new URLSearchParams(window.location.search);
9
+ return params.get('consumer') || 'web user';
10
+ }
11
+
12
+ export async function fetchAndRenderMessages(room, consumer) {
13
+ const url = consumer
14
+ ? `/api/rooms/${room}/messages?consumer=${encodeURIComponent(consumer)}`
15
+ : `/api/rooms/${room}/messages`;
16
+ const response = await fetch(url);
17
+ const messages = await response.json();
18
+
19
+ currentFeed = new MessageFeed();
20
+ currentFeed.setMessages(messages);
21
+ renderMessageFeed(currentFeed, document.getElementById('messages'));
22
+
23
+ return currentFeed.count;
24
+ }
25
+
26
+ export async function fetchAndAppendNewMessages(room, consumer) {
27
+ const response = await fetch(`/api/rooms/${room}/messages/new?consumer=${encodeURIComponent(consumer)}`);
28
+ const messages = await response.json();
29
+
30
+ currentFeed.appendMessages(messages);
31
+ renderMessageFeed(currentFeed, document.getElementById('messages'));
32
+
33
+ return currentFeed.count;
34
+ }
35
+
36
+ export async function postMessage(room, author, content) {
37
+ await fetch(`/api/rooms/${room}/messages`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ author, content })
41
+ });
42
+
43
+ currentFeed.messages.push({ author, content, optimistic: true });
44
+ renderMessageFeed(currentFeed, document.getElementById('messages'));
45
+
46
+ return currentFeed.count;
47
+ }
48
+
49
+ export function createKeyDownHandler(form) {
50
+ return function(event) {
51
+ if (event.key === 'Enter' && !event.shiftKey) {
52
+ event.preventDefault();
53
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,14 @@
1
+ export async function fetchAndRenderRooms(onRoomSelected) {
2
+ const response = await fetch('/api/rooms');
3
+ const rooms = await response.json();
4
+
5
+ const list = document.getElementById('rooms-list');
6
+ list.innerHTML = '';
7
+
8
+ for (const room of rooms) {
9
+ const li = document.createElement('li');
10
+ li.textContent = room;
11
+ li.addEventListener('click', () => onRoomSelected(room));
12
+ list.appendChild(li);
13
+ }
14
+ }
metadata ADDED
@@ -0,0 +1,192 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agent-chat
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Erik Thyge Madsen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sqlite3
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sinatra
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '4.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sinatra-contrib
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '4.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: puma
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '7.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rackup
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: minitest
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '5.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '5.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rake
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '13.0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '13.0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: rack-test
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '2.0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '2.0'
138
+ description: A simple chat tool for AI agents to communicate with each other via a
139
+ shared SQLite-backed message queue. Includes CLI and web UI.
140
+ executables:
141
+ - agent-chat
142
+ - agent-chat-web
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - config.ru
147
+ - exe/agent-chat
148
+ - exe/agent-chat-web
149
+ - lib/agent_chat.rb
150
+ - lib/agent_chat/argument_parser.rb
151
+ - lib/agent_chat/cli_adapter.rb
152
+ - lib/agent_chat/message.rb
153
+ - lib/agent_chat/message_formatter.rb
154
+ - lib/agent_chat/persistence.rb
155
+ - lib/agent_chat/persistence/database.rb
156
+ - lib/agent_chat/persistence/file_resolver.rb
157
+ - lib/agent_chat/service/main.rb
158
+ - lib/agent_chat/version.rb
159
+ - lib/agent_chat/web/app.rb
160
+ - lib/agent_chat/web/room_discovery_service.rb
161
+ - lib/agent_chat/web/service_factory.rb
162
+ - public/css/form.css
163
+ - public/css/layout.css
164
+ - public/css/style.css
165
+ - public/index.html
166
+ - public/js/message_feed.js
167
+ - public/js/messages.js
168
+ - public/js/rooms.js
169
+ homepage: https://github.com/beatmadsen/agent-chat
170
+ licenses:
171
+ - MIT
172
+ metadata:
173
+ source_code_uri: https://github.com/beatmadsen/agent-chat
174
+ changelog_uri: https://github.com/beatmadsen/agent-chat/blob/main/CHANGELOG.md
175
+ rdoc_options: []
176
+ require_paths:
177
+ - lib
178
+ required_ruby_version: !ruby/object:Gem::Requirement
179
+ requirements:
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: '3.0'
183
+ required_rubygems_version: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ requirements: []
189
+ rubygems_version: 3.6.7
190
+ specification_version: 4
191
+ summary: Chat messaging tool for inter-agent communication
192
+ test_files: []