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.
@@ -3,13 +3,13 @@
3
3
  module Botiasloop
4
4
  module Commands
5
5
  # Context object passed to command executions
6
- # Provides access to conversation, config, channel, and user info
6
+ # Provides access to conversation, chat, config, channel, and user info
7
7
  class Context
8
8
  # @return [Conversation] The current conversation
9
9
  attr_accessor :conversation
10
10
 
11
- # @return [Config] The bot configuration
12
- attr_reader :config
11
+ # @return [Chat] The chat this conversation belongs to
12
+ attr_reader :chat
13
13
 
14
14
  # @return [Channels::Base, nil] The channel instance (nil in CLI)
15
15
  attr_reader :channel
@@ -20,12 +20,12 @@ module Botiasloop
20
20
  # Initialize context
21
21
  #
22
22
  # @param conversation [Conversation] The current conversation
23
- # @param config [Config] The bot configuration
23
+ # @param chat [Chat] The chat this conversation belongs to
24
24
  # @param channel [Channels::Base, nil] The channel instance (nil in CLI)
25
25
  # @param user_id [String, nil] The user/source identifier
26
- def initialize(conversation:, config:, channel: nil, user_id: nil)
26
+ def initialize(conversation:, chat:, channel: nil, user_id: nil)
27
27
  @conversation = conversation
28
- @config = config
28
+ @chat = chat
29
29
  @channel = channel
30
30
  @user_id = user_id
31
31
  end
@@ -16,10 +16,15 @@ module Botiasloop
16
16
  # @return [String] Formatted list of conversations
17
17
  def execute(context, args = nil)
18
18
  show_archived = args.to_s.strip.downcase == "archived"
19
- conversations = ConversationManager.list_by_user(context.user_id, archived: show_archived)
20
- current_uuid = ConversationManager.current_uuid_for(context.user_id)
19
+ current_conversation = context.conversation
21
20
 
22
- lines = show_archived ? ["**Archived Conversations**"] : ["**Conversations**"]
21
+ if show_archived
22
+ conversations = context.chat.archived_conversations
23
+ lines = ["**Archived Conversations**"]
24
+ else
25
+ conversations = context.chat.active_conversations
26
+ lines = ["**Conversations**"]
27
+ end
23
28
 
24
29
  if conversations.empty?
25
30
  lines << (show_archived ? "No archived conversations found." : "No conversations found.")
@@ -27,10 +32,10 @@ module Botiasloop
27
32
  end
28
33
 
29
34
  conversations.each do |conv|
30
- prefix = (conv[:uuid] == current_uuid) ? "[current] " : ""
31
- label = conv[:label]
35
+ prefix = (conv.id == current_conversation.id) ? "[current] " : ""
36
+ label = conv.label
32
37
  suffix = label ? " (#{label})" : ""
33
- lines << "#{prefix}#{conv[:uuid]}#{suffix}"
38
+ lines << "#{prefix}#{conv.id}#{suffix}"
34
39
  end
35
40
 
36
41
  lines.join("\n")
@@ -14,7 +14,6 @@ module Botiasloop
14
14
  # @return [String] Command response
15
15
  def execute(context, args = nil)
16
16
  conversation = context.conversation
17
- user_id = context.user_id
18
17
 
19
18
  if args.nil? || args.strip.empty?
20
19
  # Show current label
@@ -23,7 +22,7 @@ module Botiasloop
23
22
 
24
23
  # Set label
25
24
  label_value = args.strip
26
- set_label(conversation, user_id, label_value)
25
+ set_label(conversation, label_value)
27
26
  end
28
27
 
29
28
  private
@@ -36,28 +35,27 @@ module Botiasloop
36
35
  end
37
36
  end
38
37
 
39
- def set_label(conversation, user_id, label_value)
38
+ def set_label(conversation, label_value)
40
39
  # Validate label format
41
40
  unless label_value.match?(/\A[a-zA-Z0-9_-]+\z/)
42
41
  return "Invalid label format. Use only letters, numbers, dashes, and underscores."
43
42
  end
44
43
 
45
- # Check uniqueness per user
46
- if label_in_use?(user_id, label_value, conversation.uuid)
44
+ # Check uniqueness globally
45
+ if label_in_use?(label_value, conversation.id)
47
46
  return "Label '#{label_value}' already in use by another conversation."
48
47
  end
49
48
 
50
- conversation.update(label: label_value)
49
+ conversation.label = label_value
50
+ conversation.save
51
51
  "Label set to: #{label_value}"
52
52
  rescue Botiasloop::Error => e
53
53
  "Error setting label: #{e.message}"
54
54
  end
55
55
 
56
- def label_in_use?(user_id, label, current_uuid)
57
- return false if user_id.nil?
58
-
59
- existing_uuid = ConversationManager.find_by_label(user_id, label)
60
- existing_uuid && existing_uuid != current_uuid
56
+ def label_in_use?(label, current_id)
57
+ other = Conversation.find(label: label)
58
+ other && other.id != current_id
61
59
  end
62
60
  end
63
61
  end
@@ -10,10 +10,10 @@ module Botiasloop
10
10
  description "Start a new conversation"
11
11
 
12
12
  def execute(context, _args = nil)
13
- new_conversation = ConversationManager.create_new(context.user_id)
13
+ new_conversation = context.chat.create_new_conversation
14
14
  context.conversation = new_conversation
15
15
 
16
- "**New conversation started (UUID: #{new_conversation.uuid}).**\n" \
16
+ "**New conversation started (ID: #{new_conversation.uuid}).**\n" \
17
17
  "Use `/switch #{new_conversation.uuid}` to return later."
18
18
  end
19
19
  end
@@ -9,10 +9,10 @@ module Botiasloop
9
9
 
10
10
  def execute(context, _args = nil)
11
11
  conversation = context.conversation
12
- config = context.config
12
+ config = Config.instance
13
13
 
14
14
  lines = ["**Conversation Status**"]
15
- lines << "UUID: #{conversation.uuid}"
15
+ lines << "ID: #{conversation.uuid}"
16
16
  lines << "Label: #{format_label(conversation)}"
17
17
  lines << "Model: #{config.providers["openrouter"]["model"]}"
18
18
  lines << "Max iterations: #{config.max_iterations}"
@@ -2,10 +2,10 @@
2
2
 
3
3
  module Botiasloop
4
4
  module Commands
5
- # Switch command - switches to a different conversation by label or UUID
5
+ # Switch command - switches to a different conversation by label or ID
6
6
  class Switch < Base
7
7
  command :switch
8
- description "Switch to a different conversation by label or UUID"
8
+ description "Switch to a different conversation by label or ID"
9
9
 
10
10
  # Execute the switch command
11
11
  #
@@ -15,11 +15,9 @@ module Botiasloop
15
15
  def execute(context, args = nil)
16
16
  identifier = args.to_s.strip
17
17
 
18
- if identifier.empty?
19
- return "Usage: /switch <label-or-uuid>"
20
- end
18
+ return "Usage: /switch <label-or-id>" if identifier.empty?
21
19
 
22
- new_conversation = ConversationManager.switch(context.user_id, identifier)
20
+ new_conversation = context.chat.switch_conversation(identifier)
23
21
  context.conversation = new_conversation
24
22
 
25
23
  format_switch_response(new_conversation)
@@ -31,7 +29,7 @@ module Botiasloop
31
29
 
32
30
  def format_switch_response(conversation)
33
31
  lines = ["**Conversation switched**"]
34
- lines << "- UUID: #{conversation.uuid}"
32
+ lines << "- ID: #{conversation.uuid}"
35
33
 
36
34
  lines << if conversation.label?
37
35
  "- Label: #{conversation.label}"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Verbose command - controls verbose mode for tool call output
6
+ class Verbose < Base
7
+ command :verbose
8
+ description "Toggle verbose mode (shows reasoning and tool calls). Usage: /verbose [on|off]"
9
+
10
+ def execute(context, args = nil)
11
+ conversation = context.conversation
12
+
13
+ case args&.downcase&.strip
14
+ when "on"
15
+ conversation.update(verbose: true)
16
+ "Verbose mode enabled. Tool calls will be shown."
17
+ when "off"
18
+ conversation.update(verbose: false)
19
+ "Verbose mode disabled. Tool calls will be hidden."
20
+ when nil, ""
21
+ status = conversation.verbose ? "on" : "off"
22
+ "Verbose mode is currently #{status}. Usage: /verbose [on|off]"
23
+ else
24
+ "Unknown argument: #{args}. Usage: /verbose [on|off]"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -5,6 +5,7 @@ require_relative "commands/base"
5
5
  require_relative "commands/registry"
6
6
  require_relative "commands/help"
7
7
  require_relative "commands/status"
8
+ require_relative "commands/verbose"
8
9
  require_relative "commands/reset"
9
10
  require_relative "commands/new"
10
11
  require_relative "commands/compact"
@@ -6,8 +6,19 @@ Anyway::Settings.default_config_path = ->(_) { File.expand_path("~/.config/botia
6
6
 
7
7
  module Botiasloop
8
8
  class Config < Anyway::Config
9
+ @instance = nil
10
+
11
+ class << self
12
+ def instance
13
+ @instance ||= new
14
+ end
15
+
16
+ attr_writer :instance
17
+ end
18
+
9
19
  attr_config \
10
20
  max_iterations: 20,
21
+ log_level: "info",
11
22
  tools: {
12
23
  web_search: {}
13
24
  },
@@ -23,6 +34,11 @@ module Botiasloop
23
34
  },
24
35
  commands: {
25
36
  summarize: {}
37
+ },
38
+ features: {
39
+ auto_labelling: {
40
+ enabled: true
41
+ }
26
42
  }
27
43
 
28
44
  # Validation
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
3
  require "time"
5
4
 
6
5
  module Botiasloop
7
6
  # Conversation model - represents a chat conversation with messages
8
7
  # Direct Sequel model with all database and business logic combined
9
8
  class Conversation < Sequel::Model(:conversations)
9
+ # Valid label format: alphanumeric, dashes, and underscores
10
+ LABEL_REGEX = /\A[a-zA-Z0-9_-]+\z/
11
+
10
12
  # Message model nested within Conversation namespace
11
13
  class Message < Sequel::Model(:messages)
12
14
  plugin :validation_helpers
@@ -17,7 +19,7 @@ module Botiasloop
17
19
  # Validations
18
20
  def validate
19
21
  super
20
- validates_presence [:conversation_id, :role, :content]
22
+ validates_presence %i[conversation_id role content]
21
23
  end
22
24
 
23
25
  # Convert message to hash for API compatibility
@@ -42,21 +44,21 @@ module Botiasloop
42
44
  # Allow setting primary key (id) for UUID
43
45
  unrestrict_primary_key
44
46
 
45
- # Auto-generate UUID before creation if not provided
47
+ # Auto-generate human-readable ID before creation if not provided
46
48
  def before_create
47
- self.id ||= SecureRandom.uuid
49
+ self.id ||= HumanId.generate
48
50
  super
49
51
  end
50
52
 
51
53
  # Validations
52
54
  def validate
53
55
  super
54
- validates_presence [:user_id]
55
56
 
56
- if label && !label.to_s.empty?
57
- validates_format ConversationManager::LABEL_REGEX, :label, message: "Invalid label format. Use only letters, numbers, dashes, and underscores."
58
- validates_unique [:user_id, :label], message: "Label '#{label}' already in use by another conversation"
59
- end
57
+ return unless label && !label.to_s.empty?
58
+
59
+ validates_format LABEL_REGEX, :label,
60
+ message: "Invalid label format. Use only letters, numbers, dashes, and underscores."
61
+ validates_unique :label, message: "Label '#{label}' already in use by another conversation"
60
62
  end
61
63
 
62
64
  # Check if this conversation has a label
@@ -66,6 +68,30 @@ module Botiasloop
66
68
  !label.nil? && !label.to_s.empty?
67
69
  end
68
70
 
71
+ # Check if this conversation is archived
72
+ #
73
+ # @return [Boolean] True if archived
74
+ def archived?
75
+ archived == true
76
+ end
77
+
78
+ # Set the label for this conversation
79
+ #
80
+ # @param value [String] Label value
81
+ def label=(value)
82
+ # Allow empty string to be treated as nil (clearing the label)
83
+ value = nil if value.to_s.empty?
84
+ super
85
+ end
86
+
87
+ # Archive this conversation
88
+ #
89
+ # @return [Conversation] self
90
+ def archive!
91
+ update(archived: true)
92
+ self
93
+ end
94
+
69
95
  # Get the timestamp of the last activity in the conversation
70
96
  #
71
97
  # @return [String, nil] ISO8601 timestamp of last message, or nil if no messages
@@ -126,10 +152,10 @@ module Botiasloop
126
152
  messages_dataset.order(:timestamp).map(&:to_hash)
127
153
  end
128
154
 
129
- # @return [String] UUID of the conversation
155
+ # @return [String] Human-readable ID of the conversation
130
156
  def uuid
131
157
  # Return existing id or generate a new one for unsaved records
132
- self.id ||= SecureRandom.uuid
158
+ self.id ||= HumanId.generate
133
159
  end
134
160
 
135
161
  # Reset conversation - clear all messages and reset token counts
@@ -183,7 +209,70 @@ module Botiasloop
183
209
  SKILLS
184
210
  end
185
211
 
212
+ prompt += build_operator_section
213
+ prompt += build_identity_section
214
+
186
215
  prompt
187
216
  end
217
+
218
+ private
219
+
220
+ def build_identity_section
221
+ path = File.expand_path("~/IDENTITY.md")
222
+ content = File.exist?(path) ? File.read(path).strip : nil
223
+
224
+ section = "\n\nIDENTITY.md\nDefines who you are - your name, personality, and how you behave.\n\n"
225
+
226
+ if content && !content.empty?
227
+ section += "#{content}\n\n"
228
+ section += "You can update ~/IDENTITY.md when the operator wants you to act differently or call you differently (eg. 'Be more concise from now on')."
229
+ elsif content && content.empty?
230
+ section += "CRITICAL: This file is empty. After setting up OPERATOR.md, ask the operator:\n\n"
231
+ section += "1. What name should I use for myself?\n"
232
+ section += "2. How formal or casual should I be?\n"
233
+ section += "3. Should I be concise or detailed in responses?\n"
234
+ section += "4. Any other personality preferences?\n\n"
235
+ section += "Then create ~/IDENTITY.md with this information."
236
+ else
237
+ section += "CRITICAL: This file does not exist. After setting up OPERATOR.md, ask the operator:\n\n"
238
+ section += "1. What name should I use for myself?\n"
239
+ section += "2. How formal or casual should I be?\n"
240
+ section += "3. Should I be concise or detailed in responses?\n"
241
+ section += "4. Any other personality preferences?\n\n"
242
+ section += "Then create ~/IDENTITY.md with: `cat > ~/IDENTITY.md << 'EOF'\n[content here]\nEOF`"
243
+ end
244
+
245
+ section
246
+ end
247
+
248
+ def build_operator_section
249
+ path = File.expand_path("~/OPERATOR.md")
250
+ content = File.exist?(path) ? File.read(path).strip : nil
251
+
252
+ section = "\n\nOPERATOR.md\nInformation about the operator - their preferences, context, background, and more.\n\n"
253
+
254
+ if content && !content.empty?
255
+ section += "#{content}\n\n"
256
+ section += "You can update ~/OPERATOR.md when you learn something new about the operator (eg. their name, birthday, what they like, etc.)."
257
+ elsif content && content.empty?
258
+ section += "CRITICAL: This file is empty. Before helping with other tasks, you MUST:\n\n"
259
+ section += "1. Ask the operator their name\n"
260
+ section += "2. Ask their location and timezone\n"
261
+ section += "3. Ask their preferred language\n"
262
+ section += "4. Ask about their interests, hobbies, or work\n"
263
+ section += "5. Ask any relevant personal context they're comfortable sharing (birthday, family, etc.)\n\n"
264
+ section += "Then create ~/OPERATOR.md with this information."
265
+ else
266
+ section += "CRITICAL: This file does not exist. Before helping with other tasks, you MUST:\n\n"
267
+ section += "1. Ask the operator their name\n"
268
+ section += "2. Ask their location and timezone\n"
269
+ section += "3. Ask their preferred language\n"
270
+ section += "4. Ask about their interests, hobbies, or work\n"
271
+ section += "5. Ask any relevant personal context they're comfortable sharing (birthday, family, etc.)\n\n"
272
+ section += "Then create ~/OPERATOR.md with: `cat > ~/OPERATOR.md << 'EOF'\n[content here]\nEOF`"
273
+ end
274
+
275
+ section
276
+ end
188
277
  end
189
278
  end
@@ -34,6 +34,7 @@ module Botiasloop
34
34
  db = connect
35
35
  db[:messages].delete if db.table_exists?(:messages)
36
36
  db[:conversations].delete if db.table_exists?(:conversations)
37
+ db[:chats].delete if db.table_exists?(:chats)
37
38
  end
38
39
 
39
40
  # Close database connection
@@ -52,20 +53,31 @@ module Botiasloop
52
53
  # Ensure directory exists
53
54
  FileUtils.mkdir_p(File.dirname(DEFAULT_PATH))
54
55
 
56
+ # Create chats table
57
+ db.create_table?(:chats) do
58
+ primary_key :id
59
+ String :channel, null: false
60
+ String :external_id, null: false
61
+ String :user_identifier
62
+ String :current_conversation_id
63
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
64
+ DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
65
+
66
+ index %i[channel external_id], unique: true
67
+ end
68
+
55
69
  # Create conversations table
56
70
  db.create_table?(:conversations) do
57
71
  String :id, primary_key: true
58
- String :user_id, null: false
59
72
  String :label
60
- TrueClass :is_current, default: false
61
73
  TrueClass :archived, default: false
74
+ TrueClass :verbose, default: false
62
75
  Integer :input_tokens, default: 0
63
76
  Integer :output_tokens, default: 0
64
77
  DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
65
78
  DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
66
79
 
67
- index [:user_id, :label], unique: true
68
- index [:user_id, :archived]
80
+ index :label, unique: true
69
81
  end
70
82
 
71
83
  # Create messages table
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffaker"
4
+
5
+ module Botiasloop
6
+ # Generates human-readable identifiers for conversations
7
+ # Format: color-animal-XXX (e.g., blue-dog-123)
8
+ # All IDs are stored and compared in lowercase for case-insensitivity
9
+ module HumanId
10
+ MAX_RETRIES = 10
11
+
12
+ # Generate a unique human-readable ID
13
+ # Checks database for collisions and retries if needed
14
+ #
15
+ # @return [String] Unique ID in format color-animal-XXX
16
+ # @raise [Error] If unable to generate unique ID after max retries
17
+ def self.generate
18
+ retries = 0
19
+
20
+ loop do
21
+ id = build_id
22
+ return id unless exists?(id)
23
+
24
+ retries += 1
25
+ raise Error, "Failed to generate unique ID after #{MAX_RETRIES} attempts" if retries >= MAX_RETRIES
26
+ end
27
+ end
28
+
29
+ # Normalize an ID to lowercase for storage and comparison
30
+ #
31
+ # @param id [String, nil] ID to normalize
32
+ # @return [String] Normalized lowercase ID
33
+ def self.normalize(id)
34
+ id.to_s.downcase.strip
35
+ end
36
+
37
+ # Check if an ID already exists in the database
38
+ # Case-insensitive comparison
39
+ #
40
+ # @param id [String] ID to check
41
+ # @return [Boolean] True if ID exists
42
+ def self.exists?(id)
43
+ normalized = normalize(id)
44
+ Conversation.where(Sequel.function(:lower, :id) => normalized).count > 0
45
+ end
46
+
47
+ # Build a single ID attempt
48
+ #
49
+ # @return [String] ID in format color-animal-XXX
50
+ def self.build_id
51
+ color = FFaker::Color.name.downcase.tr(" ", "-")
52
+ animal = FFaker::AnimalUS.common_name.downcase.tr(" ", "-")
53
+ number = rand(100..999)
54
+
55
+ "#{color}-#{animal}-#{number}"
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Botiasloop
6
+ module Logger
7
+ class << self
8
+ def logger
9
+ @logger ||= create_logger
10
+ end
11
+
12
+ def debug(msg)
13
+ logger.debug(msg)
14
+ end
15
+
16
+ def info(msg)
17
+ logger.info(msg)
18
+ end
19
+
20
+ def warn(msg)
21
+ logger.warn(msg)
22
+ end
23
+
24
+ def error(msg)
25
+ logger.error(msg)
26
+ end
27
+
28
+ private
29
+
30
+ def create_logger
31
+ level = begin
32
+ config = Botiasloop::Config.new
33
+ ::Logger.const_get(config.log_level.to_s.upcase)
34
+ rescue
35
+ ::Logger::INFO
36
+ end
37
+
38
+ log = ::Logger.new($stderr)
39
+ log.level = level
40
+ log.formatter = proc { |_severity, _datetime, _progname, msg| "#{msg}\n" }
41
+ log
42
+ end
43
+ end
44
+ end
45
+ end