botiasloop 0.0.1 → 0.0.7

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.
@@ -1,16 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_llm"
4
- require "logger"
5
4
 
6
5
  module Botiasloop
7
6
  class Agent
7
+ @instance = nil
8
+
9
+ class << self
10
+ # @return [Agent] Singleton instance of the agent
11
+ def instance
12
+ @instance ||= new
13
+ end
14
+
15
+ # @return [Agent] Singleton instance of the agent (alias for instance)
16
+ def chat(message, conversation: nil, verbose_callback: nil)
17
+ instance.chat(message, conversation: conversation, verbose_callback: verbose_callback)
18
+ end
19
+
20
+ # Set the instance directly (primarily for testing)
21
+ # @param agent [Agent, nil] Agent instance or nil to reset
22
+ attr_writer :instance
23
+ end
8
24
  # Initialize the agent
9
- #
10
- # @param config [Config, nil] Configuration instance (loads default if nil)
11
- def initialize(config = nil)
12
- @config = config || Config.new
13
- @logger = Logger.new($stderr)
25
+ def initialize
14
26
  setup_ruby_llm
15
27
  end
16
28
 
@@ -18,15 +30,16 @@ module Botiasloop
18
30
  #
19
31
  # @param message [String] User message
20
32
  # @param conversation [Conversation, nil] Existing conversation
33
+ # @param verbose_callback [Proc, nil] Callback for verbose messages
21
34
  # @return [String] Assistant response
22
- def chat(message, conversation: nil)
35
+ def chat(message, conversation: nil, verbose_callback: nil)
23
36
  conversation ||= Conversation.new
24
37
 
25
38
  registry = create_registry
26
39
  provider, model = create_provider_and_model
27
- loop = Loop.new(provider, model, registry, max_iterations: @config.max_iterations)
40
+ loop = Loop.new(provider, model, registry, max_iterations: Config.instance.max_iterations)
28
41
 
29
- loop.run(conversation, message)
42
+ loop.run(conversation, message, verbose_callback)
30
43
  rescue MaxIterationsExceeded => e
31
44
  e.message
32
45
  end
@@ -34,7 +47,7 @@ module Botiasloop
34
47
  private
35
48
 
36
49
  def setup_ruby_llm
37
- provider_name, provider_config = @config.active_provider
50
+ provider_name, provider_config = Config.instance.active_provider
38
51
 
39
52
  RubyLLM.configure do |config|
40
53
  configure_provider(config, provider_name, provider_config)
@@ -85,7 +98,7 @@ module Botiasloop
85
98
  end
86
99
 
87
100
  def create_provider_and_model
88
- _provider_name, provider_config = @config.active_provider
101
+ _provider_name, provider_config = Config.instance.active_provider
89
102
  model_id = provider_config["model"]
90
103
  model = RubyLLM::Models.find(model_id)
91
104
  provider_class = RubyLLM::Provider.for(model_id)
@@ -106,7 +119,7 @@ module Botiasloop
106
119
  end
107
120
 
108
121
  def web_search_url
109
- @config.tools["web_search"]["searxng_url"]
122
+ Config.instance.tools["web_search"]["searxng_url"]
110
123
  end
111
124
  end
112
125
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "logger"
5
+
6
+ module Botiasloop
7
+ # Service class for automatically generating conversation labels
8
+ # Triggered after 3rd user message if no label is set
9
+ class AutoLabel
10
+ MIN_MESSAGES_FOR_AUTO_LABEL = 6 # 3 user + 3 assistant messages
11
+
12
+ # Generate a label for the conversation if conditions are met
13
+ #
14
+ # @param conversation [Conversation] The conversation to label
15
+ # @return [String, nil] The generated label or nil if not applicable
16
+ def self.generate(conversation)
17
+ return nil unless should_generate?(conversation)
18
+
19
+ label = new.generate_label(conversation)
20
+
21
+ Logger.info "[AutoLabel] Generated label '#{label}' for conversation #{conversation.uuid}" if label
22
+
23
+ label
24
+ end
25
+
26
+ # Check if auto-labelling should run
27
+ #
28
+ # @param conversation [Conversation] The conversation to check
29
+ # @return [Boolean] True if conditions are met
30
+ def self.should_generate?(conversation)
31
+ return false unless Config.instance.features&.dig("auto_labelling", "enabled") != false
32
+ return false if conversation.label?
33
+ return false if conversation.message_count < MIN_MESSAGES_FOR_AUTO_LABEL
34
+
35
+ true
36
+ end
37
+
38
+ def initialize
39
+ @config = Config.instance
40
+ end
41
+
42
+ # Generate a label based on conversation content
43
+ #
44
+ # @param conversation [Conversation] The conversation to label
45
+ # @return [String, nil] The generated and formatted label
46
+ def generate_label(conversation)
47
+ messages = conversation.history
48
+ raw_label = generate_label_text(messages)
49
+ return nil unless raw_label
50
+
51
+ formatted_label = format_label(raw_label)
52
+ return nil unless valid_label?(formatted_label)
53
+
54
+ formatted_label
55
+ end
56
+
57
+ private
58
+
59
+ def generate_label_text(messages)
60
+ chat = create_chat
61
+
62
+ conversation_text = messages.first(MIN_MESSAGES_FOR_AUTO_LABEL).map do |msg|
63
+ "#{msg[:role]}: #{msg[:content]}"
64
+ end.join("\n\n")
65
+
66
+ prompt = <<~PROMPT
67
+ Based on the following conversation, generate a short label (1-2 words) that describes the topic.
68
+ Use lowercase letters only. If two words, separate them with a dash (-).
69
+ Examples: "coding-help", "travel-planning", "recipe-ideas", "debugging"
70
+
71
+ Conversation:
72
+ #{conversation_text}
73
+
74
+ Label (respond with just the label, nothing else):
75
+ PROMPT
76
+
77
+ chat.add_message(role: :user, content: prompt)
78
+ response = chat.complete
79
+
80
+ response.content&.strip
81
+ rescue
82
+ nil
83
+ end
84
+
85
+ def create_chat
86
+ label_config = @config.features["auto_labelling"] || {}
87
+
88
+ if label_config["model"]
89
+ RubyLLM.chat(model: label_config["model"])
90
+ else
91
+ default_model = @config.providers["openrouter"]["model"]
92
+ RubyLLM.chat(model: default_model)
93
+ end
94
+ end
95
+
96
+ def format_label(raw_label)
97
+ # Remove non-alphanumeric characters except dashes, underscores, and spaces
98
+ cleaned = raw_label.gsub(/[^a-zA-Z0-9\s\-_]/, "")
99
+
100
+ # Split into words (by whitespace only, preserve underscores in words)
101
+ words = cleaned.split(/\s+/).reject(&:empty?)
102
+
103
+ # Take max 2 words
104
+ words = words.first(2)
105
+
106
+ # Join with dash, lowercase
107
+ words.join("-").downcase
108
+ end
109
+
110
+ def valid_label?(label)
111
+ return false if label.nil? || label.empty?
112
+ return false unless label.match?(Conversation::LABEL_REGEX)
113
+
114
+ true
115
+ end
116
+ end
117
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
- require "logger"
6
5
 
7
6
  module Botiasloop
8
7
  module Channels
@@ -14,9 +13,7 @@ module Botiasloop
14
13
  # @param name [Symbol] Channel identifier (e.g., :telegram)
15
14
  # @return [Symbol] The channel identifier
16
15
  def channel_name(name = nil)
17
- if name
18
- @channel_identifier = name
19
- end
16
+ @channel_identifier = name if name
20
17
  @channel_identifier
21
18
  end
22
19
 
@@ -39,12 +36,8 @@ module Botiasloop
39
36
 
40
37
  # Initialize the channel
41
38
  #
42
- # @param config [Config] Configuration instance
43
39
  # @raise [Error] If required configuration is missing
44
- def initialize(config)
45
- @config = config
46
- @logger = Logger.new($stderr)
47
-
40
+ def initialize
48
41
  validate_required_config!
49
42
  end
50
43
 
@@ -53,19 +46,27 @@ module Botiasloop
53
46
  #
54
47
  # @return [Hash] Channel configuration hash
55
48
  def channel_config
56
- @config.channels[self.class.channel_identifier.to_s] || {}
49
+ Config.instance.channels[self.class.channel_identifier.to_s] || {}
50
+ end
51
+
52
+ # Get the channel type string (e.g., "telegram", "cli")
53
+ # Override in subclasses if needed
54
+ #
55
+ # @return [String] Channel type string
56
+ def channel_type
57
+ self.class.channel_identifier.to_s
57
58
  end
58
59
 
59
60
  # Start the channel and begin listening for messages
60
61
  # @raise [NotImplementedError] Subclass must implement
61
- def start
62
- raise NotImplementedError, "Subclass must implement #start"
62
+ def start_listening
63
+ raise NotImplementedError, "Subclass must implement #start_listening"
63
64
  end
64
65
 
65
66
  # Stop the channel and cleanup
66
67
  # @raise [NotImplementedError] Subclass must implement
67
- def stop
68
- raise NotImplementedError, "Subclass must implement #stop"
68
+ def stop_listening
69
+ raise NotImplementedError, "Subclass must implement #stop_listening"
69
70
  end
70
71
 
71
72
  # Check if the channel is currently running
@@ -80,7 +81,7 @@ module Botiasloop
80
81
  # @param source_id [String] Unique identifier for the message source (e.g., chat_id, user_id)
81
82
  # @param raw_message [Object] Raw message object (varies by channel)
82
83
  # @param metadata [Hash] Additional metadata about the message
83
- def process_message(source_id, raw_message, metadata = {})
84
+ def process_message(source_id, raw_message, _metadata = {})
84
85
  # Hook: Extract content from raw message
85
86
  content = extract_content(raw_message)
86
87
  return if content.nil? || content.to_s.empty?
@@ -98,22 +99,25 @@ module Botiasloop
98
99
  before_process(source_id, user_id, content, raw_message)
99
100
 
100
101
  # Core processing logic
101
- conversation = conversation_for(source_id)
102
+ chat = chat_for(source_id, user_identifier: user_id)
103
+ conversation = chat.current_conversation
102
104
 
103
105
  response = if Commands.command?(content)
104
106
  context = Commands::Context.new(
105
107
  conversation: conversation,
106
- config: @config,
108
+ chat: chat,
107
109
  channel: self,
108
110
  user_id: source_id
109
111
  )
110
112
  Commands.execute(content, context)
111
113
  else
112
- agent = Agent.new(@config)
113
- agent.chat(content, conversation: conversation)
114
+ verbose_callback = proc do |verbose_message|
115
+ send_message(source_id, verbose_message)
116
+ end
117
+ Agent.chat(content, conversation: conversation, verbose_callback: verbose_callback)
114
118
  end
115
119
 
116
- send_response(source_id, response)
120
+ send_message(source_id, response)
117
121
 
118
122
  # Hook: Post-processing
119
123
  after_process(source_id, user_id, response, raw_message)
@@ -136,7 +140,7 @@ module Botiasloop
136
140
  # @param source_id [String] Source identifier
137
141
  # @param raw_message [Object] Raw message object
138
142
  # @return [String] User ID for authorization
139
- def extract_user_id(source_id, raw_message)
143
+ def extract_user_id(source_id, _raw_message)
140
144
  source_id
141
145
  end
142
146
 
@@ -168,8 +172,8 @@ module Botiasloop
168
172
  # @param source_id [String] Source identifier
169
173
  # @param user_id [String] User ID that was denied
170
174
  # @param raw_message [Object] Raw message object
171
- def handle_unauthorized(source_id, user_id, raw_message)
172
- @logger.warn "[#{self.class.channel_identifier}] Unauthorized access from #{user_id} (source: #{source_id})"
175
+ def handle_unauthorized(source_id, user_id, _raw_message)
176
+ Logger.warn "[#{self.class.channel_identifier}] Unauthorized access from #{user_id} (source: #{source_id})"
173
177
  end
174
178
 
175
179
  # Handle errors during message processing
@@ -179,8 +183,8 @@ module Botiasloop
179
183
  # @param user_id [String] User ID
180
184
  # @param error [Exception] The error that occurred
181
185
  # @param raw_message [Object] Raw message object
182
- def handle_error(source_id, user_id, error, raw_message)
183
- @logger.error "[#{self.class.channel_identifier}] Error processing message: #{error.message}"
186
+ def handle_error(_source_id, _user_id, error, _raw_message)
187
+ Logger.error "[#{self.class.channel_identifier}] Error processing message: #{error.message}"
184
188
  raise error
185
189
  end
186
190
 
@@ -188,43 +192,43 @@ module Botiasloop
188
192
  #
189
193
  # @param source_id [String] Source identifier to check
190
194
  # @return [Boolean] False by default (secure default)
191
- def authorized?(source_id)
195
+ def authorized?(_source_id)
192
196
  false
193
197
  end
194
198
 
195
- # Get or create a conversation for a source
196
- # Uses the global ConversationManager for state management.
199
+ # Get or create a chat for a source
197
200
  #
198
201
  # @param source_id [String] Source identifier
199
- # @return [Conversation] Conversation instance
200
- def conversation_for(source_id)
201
- ConversationManager.current_for(source_id)
202
+ # @param user_identifier [String, nil] Optional user identifier (e.g., username)
203
+ # @return [Chat] Chat instance
204
+ def chat_for(source_id, user_identifier: nil)
205
+ Chat.find_or_create(channel_type, source_id, user_identifier: user_identifier)
202
206
  end
203
207
 
204
- # Format a response for this channel
208
+ # Format a message for this channel
205
209
  #
206
- # @param content [String] Raw response content
207
- # @return [String] Formatted response
208
- def format_response(content)
210
+ # @param content [String] Raw message content
211
+ # @return [String] Formatted message
212
+ def format_message(content)
209
213
  content
210
214
  end
211
215
 
212
- # Send a response to a source
216
+ # Send a message to a source
213
217
  #
214
218
  # @param source_id [String] Source identifier
215
- # @param response [String] Response content
216
- def send_response(source_id, response)
217
- formatted = format_response(response)
218
- deliver_response(source_id, formatted)
219
+ # @param message [String] Message content
220
+ def send_message(source_id, message)
221
+ formatted = format_message(message)
222
+ deliver_message(source_id, formatted)
219
223
  end
220
224
 
221
- # Deliver a formatted response to a source
225
+ # Deliver a formatted message to a source
222
226
  #
223
227
  # @param source_id [String] Source identifier
224
- # @param formatted_content [String] Formatted response content
228
+ # @param formatted_content [String] Formatted message content
225
229
  # @raise [NotImplementedError] Subclass must implement
226
- def deliver_response(source_id, formatted_content)
227
- raise NotImplementedError, "Subclass must implement #deliver_response"
230
+ def deliver_message(source_id, formatted_content)
231
+ raise NotImplementedError, "Subclass must implement #deliver_message"
228
232
  end
229
233
 
230
234
  private
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "logger"
4
-
5
3
  module Botiasloop
6
4
  module Channels
7
5
  class CLI < Base
@@ -11,17 +9,15 @@ module Botiasloop
11
9
  SOURCE_ID = "cli"
12
10
 
13
11
  # Initialize CLI channel
14
- #
15
- # @param config [Config] Configuration instance
16
- def initialize(config)
12
+ def initialize
17
13
  super
18
14
  @running = false
19
15
  end
20
16
 
21
17
  # Start the CLI interactive mode
22
- def start
18
+ def start_listening
23
19
  @running = true
24
- @logger.info "[CLI] Starting interactive mode..."
20
+ Logger.info "[CLI] Starting interactive mode..."
25
21
 
26
22
  puts "botiasloop v#{VERSION} - Interactive Mode"
27
23
  puts "Type 'exit', 'quit', or '\\q' to exit"
@@ -37,17 +33,17 @@ module Botiasloop
37
33
  end
38
34
 
39
35
  @running = false
40
- @logger.info "[CLI] Interactive mode ended"
36
+ Logger.info "[CLI] Interactive mode ended"
41
37
  rescue Interrupt
42
38
  @running = false
43
39
  puts "\nGoodbye!"
44
- @logger.info "[CLI] Interrupted by user"
40
+ Logger.info "[CLI] Interrupted by user"
45
41
  end
46
42
 
47
43
  # Stop the CLI channel
48
- def stop
44
+ def stop_listening
49
45
  @running = false
50
- @logger.info "[CLI] Stopping..."
46
+ Logger.info "[CLI] Stopping..."
51
47
  end
52
48
 
53
49
  # Check if CLI channel is running
@@ -70,7 +66,7 @@ module Botiasloop
70
66
  #
71
67
  # @param source_id [String] Source identifier to check
72
68
  # @return [Boolean] Always true for CLI
73
- def authorized?(source_id)
69
+ def authorized?(_source_id)
74
70
  true
75
71
  end
76
72
 
@@ -80,16 +76,16 @@ module Botiasloop
80
76
  # @param user_id [String] User ID
81
77
  # @param error [Exception] The error that occurred
82
78
  # @param raw_message [Object] Raw message object
83
- def handle_error(source_id, user_id, error, raw_message)
84
- @logger.error "[CLI] Error processing message: #{error.message}"
85
- send_response(source_id, "Error: #{error.message}")
79
+ def handle_error(source_id, _user_id, error, _raw_message)
80
+ Logger.error "[CLI] Error processing message: #{error.message}"
81
+ send_message(source_id, "Error: #{error.message}")
86
82
  end
87
83
 
88
- # Deliver a formatted response to the CLI
84
+ # Deliver a formatted message to the CLI
89
85
  #
90
86
  # @param source_id [String] Source identifier
91
- # @param formatted_content [String] Formatted response content
92
- def deliver_response(source_id, formatted_content)
87
+ # @param formatted_content [String] Formatted message content
88
+ def deliver_message(_source_id, formatted_content)
93
89
  puts "Agent: #{formatted_content}"
94
90
  puts
95
91
  end