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.
@@ -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
@@ -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
@@ -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