kodo-bot 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +157 -0
- data/bin/kodo +160 -0
- data/config/default.yml +40 -0
- data/lib/kodo/channels/base.rb +36 -0
- data/lib/kodo/channels/console.rb +45 -0
- data/lib/kodo/channels/telegram.rb +138 -0
- data/lib/kodo/config.rb +142 -0
- data/lib/kodo/daemon.rb +79 -0
- data/lib/kodo/heartbeat.rb +97 -0
- data/lib/kodo/llm.rb +30 -0
- data/lib/kodo/memory/audit.rb +55 -0
- data/lib/kodo/memory/store.rb +77 -0
- data/lib/kodo/message.rb +26 -0
- data/lib/kodo/prompt_assembler.rb +228 -0
- data/lib/kodo/router.rb +65 -0
- data/lib/kodo/version.rb +5 -0
- data/lib/kodo.rb +40 -0
- metadata +146 -0
data/lib/kodo/config.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Kodo
|
|
7
|
+
class Config
|
|
8
|
+
# Map of Kodo config key → RubyLLM config setter name
|
|
9
|
+
PROVIDER_KEY_MAP = {
|
|
10
|
+
"anthropic" => "anthropic",
|
|
11
|
+
"openai" => "openai",
|
|
12
|
+
"gemini" => "gemini",
|
|
13
|
+
"deepseek" => "deepseek",
|
|
14
|
+
"mistral" => "mistral",
|
|
15
|
+
"openrouter" => "openrouter",
|
|
16
|
+
"perplexity" => "perplexity",
|
|
17
|
+
"xai" => "xai"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
DEFAULTS = {
|
|
21
|
+
"daemon" => {
|
|
22
|
+
"port" => 7377,
|
|
23
|
+
"heartbeat_interval" => 60
|
|
24
|
+
},
|
|
25
|
+
"llm" => {
|
|
26
|
+
"model" => "claude-sonnet-4-20250514",
|
|
27
|
+
"providers" => {
|
|
28
|
+
"anthropic" => { "api_key_env" => "ANTHROPIC_API_KEY" }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"channels" => {
|
|
32
|
+
"telegram" => {
|
|
33
|
+
"enabled" => false,
|
|
34
|
+
"bot_token_env" => "TELEGRAM_BOT_TOKEN"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"memory" => {
|
|
38
|
+
"encryption" => false,
|
|
39
|
+
"store" => "file"
|
|
40
|
+
},
|
|
41
|
+
"logging" => {
|
|
42
|
+
"level" => "info",
|
|
43
|
+
"audit" => true
|
|
44
|
+
}
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
attr_reader :data
|
|
48
|
+
|
|
49
|
+
def initialize(data)
|
|
50
|
+
@data = data
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
def load(path = nil)
|
|
55
|
+
path ||= config_path
|
|
56
|
+
user_config = File.exist?(path) ? YAML.safe_load_file(path) : {}
|
|
57
|
+
merged = deep_merge(DEFAULTS, user_config || {})
|
|
58
|
+
new(merged)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def config_path
|
|
62
|
+
File.join(Kodo.home_dir, "config.yml")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ensure_home_dir!
|
|
66
|
+
dirs = [
|
|
67
|
+
Kodo.home_dir,
|
|
68
|
+
File.join(Kodo.home_dir, "memory", "conversations"),
|
|
69
|
+
File.join(Kodo.home_dir, "memory", "knowledge"),
|
|
70
|
+
File.join(Kodo.home_dir, "memory", "audit"),
|
|
71
|
+
File.join(Kodo.home_dir, "skills")
|
|
72
|
+
]
|
|
73
|
+
dirs.each { |d| FileUtils.mkdir_p(d) }
|
|
74
|
+
|
|
75
|
+
unless File.exist?(config_path)
|
|
76
|
+
File.write(config_path, YAML.dump(DEFAULTS))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def deep_merge(base, override)
|
|
83
|
+
base.merge(override) do |_key, old_val, new_val|
|
|
84
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
85
|
+
deep_merge(old_val, new_val)
|
|
86
|
+
else
|
|
87
|
+
new_val
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Daemon ---
|
|
94
|
+
def port = data.dig("daemon", "port")
|
|
95
|
+
def heartbeat_interval = data.dig("daemon", "heartbeat_interval")
|
|
96
|
+
|
|
97
|
+
# --- LLM ---
|
|
98
|
+
def llm_model = data.dig("llm", "model")
|
|
99
|
+
|
|
100
|
+
# Returns a hash of { "provider_name" => "actual_api_key" } for all configured providers
|
|
101
|
+
def llm_api_keys
|
|
102
|
+
providers = data.dig("llm", "providers") || {}
|
|
103
|
+
keys = {}
|
|
104
|
+
|
|
105
|
+
providers.each do |provider, settings|
|
|
106
|
+
env_var = settings["api_key_env"]
|
|
107
|
+
next unless env_var
|
|
108
|
+
|
|
109
|
+
key = ENV[env_var]
|
|
110
|
+
if key && !key.empty?
|
|
111
|
+
ruby_llm_name = PROVIDER_KEY_MAP[provider] || provider
|
|
112
|
+
keys[ruby_llm_name] = key
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if keys.empty?
|
|
117
|
+
raise Error, "No LLM API keys found. Set at least one provider key (e.g. ANTHROPIC_API_KEY)"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
keys
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Optional: Ollama base URL for local models
|
|
124
|
+
def ollama_api_base
|
|
125
|
+
data.dig("llm", "providers", "ollama", "api_base") || ENV["OLLAMA_API_BASE"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- Logging ---
|
|
129
|
+
def log_level = data.dig("logging", "level")&.to_sym || :info
|
|
130
|
+
def audit_enabled? = data.dig("logging", "audit") != false
|
|
131
|
+
|
|
132
|
+
# --- Channels ---
|
|
133
|
+
def telegram_bot_token
|
|
134
|
+
env_var = data.dig("channels", "telegram", "bot_token_env")
|
|
135
|
+
ENV.fetch(env_var) { raise Error, "Missing environment variable: #{env_var}" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def telegram_enabled?
|
|
139
|
+
data.dig("channels", "telegram", "enabled") == true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/kodo/daemon.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kodo
|
|
4
|
+
class Daemon
|
|
5
|
+
attr_reader :config, :channels, :router, :heartbeat
|
|
6
|
+
|
|
7
|
+
def initialize(config: nil, heartbeat_interval: nil)
|
|
8
|
+
@config = config || Kodo.config
|
|
9
|
+
@heartbeat_interval = heartbeat_interval || @config.heartbeat_interval
|
|
10
|
+
|
|
11
|
+
@memory = Memory::Store.new
|
|
12
|
+
@audit = Memory::Audit.new
|
|
13
|
+
@prompt_assembler = PromptAssembler.new
|
|
14
|
+
@router = Router.new(memory: @memory, audit: @audit, prompt_assembler: @prompt_assembler)
|
|
15
|
+
@channels = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start!
|
|
19
|
+
Kodo.logger.info("🥁 Kodo v#{VERSION} starting...")
|
|
20
|
+
Kodo.logger.info(" Home: #{Kodo.home_dir}")
|
|
21
|
+
|
|
22
|
+
Config.ensure_home_dir!
|
|
23
|
+
@prompt_assembler.ensure_default_files!
|
|
24
|
+
configure_llm!
|
|
25
|
+
connect_channels!
|
|
26
|
+
|
|
27
|
+
# Log which prompt files were found
|
|
28
|
+
%w[persona.md user.md pulse.md origin.md].each do |f|
|
|
29
|
+
path = File.join(Kodo.home_dir, f)
|
|
30
|
+
status = File.exist?(path) ? "✅" : " "
|
|
31
|
+
Kodo.logger.info(" #{status} #{f}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
start_heartbeat!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stop!
|
|
38
|
+
Kodo.logger.info("🥁 Kodo shutting down...")
|
|
39
|
+
@heartbeat&.stop!
|
|
40
|
+
@channels.each(&:disconnect!)
|
|
41
|
+
Kodo.logger.info("🥁 Goodbye.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def configure_llm!
|
|
47
|
+
LLM.configure!(config)
|
|
48
|
+
Kodo.logger.info(" Model: #{config.llm_model}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def connect_channels!
|
|
52
|
+
if config.telegram_enabled?
|
|
53
|
+
telegram = Channels::Telegram.new(bot_token: config.telegram_bot_token)
|
|
54
|
+
telegram.connect!
|
|
55
|
+
@channels << telegram
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if @channels.empty?
|
|
59
|
+
Kodo.logger.warn("No channels configured! Enable at least one in ~/.kodo/config.yml")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Kodo.logger.info("Connected #{@channels.length} channel(s)")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def start_heartbeat!
|
|
66
|
+
@heartbeat = Heartbeat.new(
|
|
67
|
+
channels: @channels,
|
|
68
|
+
router: @router,
|
|
69
|
+
audit: @audit,
|
|
70
|
+
interval: @heartbeat_interval
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
trap("INT") { stop! }
|
|
74
|
+
trap("TERM") { stop! }
|
|
75
|
+
|
|
76
|
+
@heartbeat.start!
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kodo
|
|
4
|
+
class Heartbeat
|
|
5
|
+
def initialize(channels:, router:, audit:, interval: 60)
|
|
6
|
+
@channels = channels
|
|
7
|
+
@router = router
|
|
8
|
+
@audit = audit
|
|
9
|
+
@interval = interval
|
|
10
|
+
@running = false
|
|
11
|
+
@beat_count = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start!
|
|
15
|
+
@running = true
|
|
16
|
+
Kodo.logger.info("💓 Heartbeat started (interval: #{@interval}s)")
|
|
17
|
+
@audit.log(event: "heartbeat_start", detail: "interval:#{@interval}s")
|
|
18
|
+
|
|
19
|
+
loop do
|
|
20
|
+
break unless @running
|
|
21
|
+
|
|
22
|
+
beat!
|
|
23
|
+
sleep(@interval)
|
|
24
|
+
end
|
|
25
|
+
rescue Interrupt
|
|
26
|
+
stop!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stop!
|
|
30
|
+
@running = false
|
|
31
|
+
Kodo.logger.info("💓 Heartbeat stopped after #{@beat_count} beats")
|
|
32
|
+
@audit.log(event: "heartbeat_stop", detail: "beats:#{@beat_count}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def running? = @running
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def beat!
|
|
40
|
+
@beat_count += 1
|
|
41
|
+
Kodo.logger.debug("💓 Beat ##{@beat_count}")
|
|
42
|
+
|
|
43
|
+
# Phase 1: Collect — poll all channels for new messages
|
|
44
|
+
incoming = collect_messages
|
|
45
|
+
|
|
46
|
+
# Phase 2: Route — send each message through the router and respond
|
|
47
|
+
incoming.each do |message, channel|
|
|
48
|
+
process_message(message, channel)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Phase 3: Schedule — check cron-like pulses (future)
|
|
52
|
+
# TODO: scheduled tasks
|
|
53
|
+
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
Kodo.logger.error("Heartbeat error: #{e.message}")
|
|
56
|
+
Kodo.logger.debug(e.backtrace&.first(5)&.join("\n"))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def collect_messages
|
|
60
|
+
messages = []
|
|
61
|
+
|
|
62
|
+
@channels.each do |channel|
|
|
63
|
+
next unless channel.running?
|
|
64
|
+
|
|
65
|
+
channel_messages = channel.poll
|
|
66
|
+
channel_messages.each do |msg|
|
|
67
|
+
messages << [msg, channel]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if messages.any?
|
|
72
|
+
Kodo.logger.debug("Collected #{messages.length} message(s) from #{@channels.length} channel(s)")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
messages
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def process_message(message, channel)
|
|
79
|
+
Kodo.logger.info("Processing: [#{channel.channel_id}] #{message.content.slice(0, 60)}...")
|
|
80
|
+
|
|
81
|
+
response = @router.route(message, channel: channel)
|
|
82
|
+
channel.send_message(response)
|
|
83
|
+
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
Kodo.logger.error("Error processing message: #{e.message}")
|
|
86
|
+
|
|
87
|
+
# Try to send an error message back to the user
|
|
88
|
+
error_msg = Message.new(
|
|
89
|
+
channel_id: channel.channel_id,
|
|
90
|
+
sender: :agent,
|
|
91
|
+
content: "Sorry, I hit an error processing that. Check the logs for details.",
|
|
92
|
+
metadata: { chat_id: message.metadata[:chat_id] || message.metadata["chat_id"] }
|
|
93
|
+
)
|
|
94
|
+
channel.send_message(error_msg) rescue nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/kodo/llm.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Kodo
|
|
6
|
+
module LLM
|
|
7
|
+
class << self
|
|
8
|
+
# Configure RubyLLM from Kodo's config
|
|
9
|
+
def configure!(config)
|
|
10
|
+
RubyLLM.configure do |c|
|
|
11
|
+
config.llm_api_keys.each do |provider, key|
|
|
12
|
+
setter = "#{provider}_api_key="
|
|
13
|
+
c.send(setter, key) if c.respond_to?(setter)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if (ollama_url = config.ollama_api_base)
|
|
17
|
+
c.ollama_api_base = ollama_url
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Kodo.logger.info("LLM configured: #{config.llm_model}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Create a new chat instance with the configured model
|
|
25
|
+
def chat(model: nil)
|
|
26
|
+
RubyLLM.chat(model: model || Kodo.config.llm_model)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Kodo
|
|
7
|
+
module Memory
|
|
8
|
+
class Audit
|
|
9
|
+
def initialize
|
|
10
|
+
@audit_dir = File.join(Kodo.home_dir, "memory", "audit")
|
|
11
|
+
FileUtils.mkdir_p(@audit_dir)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Log an event to the audit trail
|
|
15
|
+
def log(event:, channel: nil, detail: nil)
|
|
16
|
+
entry = {
|
|
17
|
+
"timestamp" => Time.now.iso8601,
|
|
18
|
+
"event" => event,
|
|
19
|
+
"channel" => channel,
|
|
20
|
+
"detail" => detail
|
|
21
|
+
}.compact
|
|
22
|
+
|
|
23
|
+
append_to_daily_log(entry)
|
|
24
|
+
Kodo.logger.debug("Audit: #{event} #{detail&.slice(0, 80)}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Read today's audit log
|
|
28
|
+
def today
|
|
29
|
+
read_log(Date.today)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def append_to_daily_log(entry)
|
|
35
|
+
path = log_path(Date.today)
|
|
36
|
+
File.open(path, "a") do |f|
|
|
37
|
+
f.puts(JSON.generate(entry))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def log_path(date)
|
|
42
|
+
File.join(@audit_dir, "#{date.iso8601}.jsonl")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def read_log(date)
|
|
46
|
+
path = log_path(date)
|
|
47
|
+
return [] unless File.exist?(path)
|
|
48
|
+
|
|
49
|
+
File.readlines(path).map { |line| JSON.parse(line) }
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
[]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Kodo
|
|
7
|
+
module Memory
|
|
8
|
+
class Store
|
|
9
|
+
MAX_CONTEXT_MESSAGES = 50 # Keep last N messages per conversation
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@conversations = {} # chat_id => Array<Hash>
|
|
13
|
+
@conversations_dir = File.join(Kodo.home_dir, "memory", "conversations")
|
|
14
|
+
FileUtils.mkdir_p(@conversations_dir)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Append a message to a conversation
|
|
18
|
+
def append(chat_id, role:, content:)
|
|
19
|
+
chat_id = chat_id.to_s
|
|
20
|
+
@conversations[chat_id] ||= load_conversation(chat_id)
|
|
21
|
+
@conversations[chat_id] << {
|
|
22
|
+
"role" => role,
|
|
23
|
+
"content" => content,
|
|
24
|
+
"timestamp" => Time.now.iso8601
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Trim to max context window
|
|
28
|
+
if @conversations[chat_id].length > MAX_CONTEXT_MESSAGES
|
|
29
|
+
@conversations[chat_id] = @conversations[chat_id].last(MAX_CONTEXT_MESSAGES)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
save_conversation(chat_id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get conversation history in LLM-ready format
|
|
36
|
+
# Returns Array of {role: "user"|"assistant", content: String}
|
|
37
|
+
def conversation(chat_id)
|
|
38
|
+
chat_id = chat_id.to_s
|
|
39
|
+
@conversations[chat_id] ||= load_conversation(chat_id)
|
|
40
|
+
@conversations[chat_id].map do |msg|
|
|
41
|
+
{ role: msg["role"], content: msg["content"] }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Clear a conversation
|
|
46
|
+
def clear(chat_id)
|
|
47
|
+
chat_id = chat_id.to_s
|
|
48
|
+
@conversations.delete(chat_id)
|
|
49
|
+
path = conversation_path(chat_id)
|
|
50
|
+
File.delete(path) if File.exist?(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def conversation_path(chat_id)
|
|
56
|
+
# Sanitize chat_id for filesystem safety
|
|
57
|
+
safe_id = chat_id.gsub(/[^a-zA-Z0-9_\-]/, "_")
|
|
58
|
+
File.join(@conversations_dir, "#{safe_id}.json")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_conversation(chat_id)
|
|
62
|
+
path = conversation_path(chat_id)
|
|
63
|
+
return [] unless File.exist?(path)
|
|
64
|
+
|
|
65
|
+
JSON.parse(File.read(path))
|
|
66
|
+
rescue JSON::ParserError => e
|
|
67
|
+
Kodo.logger.warn("Corrupt conversation file #{path}: #{e.message}")
|
|
68
|
+
[]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def save_conversation(chat_id)
|
|
72
|
+
path = conversation_path(chat_id)
|
|
73
|
+
File.write(path, JSON.pretty_generate(@conversations[chat_id]))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/kodo/message.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kodo
|
|
4
|
+
Message = Data.define(
|
|
5
|
+
:id,
|
|
6
|
+
:channel_id,
|
|
7
|
+
:sender, # :user, :agent, :system
|
|
8
|
+
:content,
|
|
9
|
+
:timestamp,
|
|
10
|
+
:metadata # Hash — channel-specific extras
|
|
11
|
+
) do
|
|
12
|
+
def initialize(id: SecureRandom.uuid, channel_id:, sender:, content:, timestamp: Time.now, metadata: {})
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def from_user? = sender == :user
|
|
17
|
+
def from_agent? = sender == :agent
|
|
18
|
+
def from_system? = sender == :system
|
|
19
|
+
|
|
20
|
+
# Convert to the format expected by LLM providers
|
|
21
|
+
def to_llm_message
|
|
22
|
+
role = from_user? ? "user" : "assistant"
|
|
23
|
+
{ role: role, content: content }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|