botiasloop 0.0.1

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +343 -0
  3. data/bin/botiasloop +155 -0
  4. data/data/skills/skill-creator/SKILL.md +329 -0
  5. data/data/skills/skill-creator/assets/ruby_api_cli_template.rb +151 -0
  6. data/data/skills/skill-creator/references/specification.md +99 -0
  7. data/lib/botiasloop/agent.rb +112 -0
  8. data/lib/botiasloop/channels/base.rb +248 -0
  9. data/lib/botiasloop/channels/cli.rb +101 -0
  10. data/lib/botiasloop/channels/telegram.rb +348 -0
  11. data/lib/botiasloop/channels.rb +64 -0
  12. data/lib/botiasloop/channels_manager.rb +299 -0
  13. data/lib/botiasloop/commands/archive.rb +109 -0
  14. data/lib/botiasloop/commands/base.rb +54 -0
  15. data/lib/botiasloop/commands/compact.rb +78 -0
  16. data/lib/botiasloop/commands/context.rb +34 -0
  17. data/lib/botiasloop/commands/conversations.rb +40 -0
  18. data/lib/botiasloop/commands/help.rb +30 -0
  19. data/lib/botiasloop/commands/label.rb +64 -0
  20. data/lib/botiasloop/commands/new.rb +21 -0
  21. data/lib/botiasloop/commands/registry.rb +121 -0
  22. data/lib/botiasloop/commands/reset.rb +18 -0
  23. data/lib/botiasloop/commands/status.rb +32 -0
  24. data/lib/botiasloop/commands/switch.rb +76 -0
  25. data/lib/botiasloop/commands/system_prompt.rb +20 -0
  26. data/lib/botiasloop/commands.rb +22 -0
  27. data/lib/botiasloop/config.rb +58 -0
  28. data/lib/botiasloop/conversation.rb +189 -0
  29. data/lib/botiasloop/conversation_manager.rb +225 -0
  30. data/lib/botiasloop/database.rb +92 -0
  31. data/lib/botiasloop/loop.rb +115 -0
  32. data/lib/botiasloop/skills/loader.rb +58 -0
  33. data/lib/botiasloop/skills/registry.rb +42 -0
  34. data/lib/botiasloop/skills/skill.rb +75 -0
  35. data/lib/botiasloop/systemd_service.rb +300 -0
  36. data/lib/botiasloop/tool.rb +24 -0
  37. data/lib/botiasloop/tools/registry.rb +68 -0
  38. data/lib/botiasloop/tools/shell.rb +50 -0
  39. data/lib/botiasloop/tools/web_search.rb +64 -0
  40. data/lib/botiasloop/version.rb +5 -0
  41. data/lib/botiasloop.rb +45 -0
  42. metadata +250 -0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Help command - displays available slash commands
6
+ class Help < Base
7
+ command :help
8
+ description "Show available commands"
9
+
10
+ # Execute the help command
11
+ #
12
+ # @param context [Context] Execution context
13
+ # @param _args [String, nil] Unused arguments
14
+ # @return [String] Formatted list of commands
15
+ def execute(context, _args = nil)
16
+ commands = Botiasloop::Commands.registry.all
17
+
18
+ lines = ["**Available commands**"]
19
+
20
+ commands.each do |cmd_class|
21
+ name = cmd_class.command_name
22
+ desc = cmd_class.description || "No description"
23
+ lines << "/#{name} - #{desc}"
24
+ end
25
+
26
+ lines.join("\n")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Label command - manages conversation labels
6
+ class Label < Base
7
+ command :label
8
+ description "Set or show conversation label"
9
+
10
+ # Execute the label command
11
+ #
12
+ # @param context [Context] Execution context
13
+ # @param args [String, nil] Label value or nil to show current
14
+ # @return [String] Command response
15
+ def execute(context, args = nil)
16
+ conversation = context.conversation
17
+ user_id = context.user_id
18
+
19
+ if args.nil? || args.strip.empty?
20
+ # Show current label
21
+ return show_label(conversation)
22
+ end
23
+
24
+ # Set label
25
+ label_value = args.strip
26
+ set_label(conversation, user_id, label_value)
27
+ end
28
+
29
+ private
30
+
31
+ def show_label(conversation)
32
+ if conversation.label?
33
+ "Current label: #{conversation.label}"
34
+ else
35
+ "No label set. Use /label <name> to set one."
36
+ end
37
+ end
38
+
39
+ def set_label(conversation, user_id, label_value)
40
+ # Validate label format
41
+ unless label_value.match?(/\A[a-zA-Z0-9_-]+\z/)
42
+ return "Invalid label format. Use only letters, numbers, dashes, and underscores."
43
+ end
44
+
45
+ # Check uniqueness per user
46
+ if label_in_use?(user_id, label_value, conversation.uuid)
47
+ return "Label '#{label_value}' already in use by another conversation."
48
+ end
49
+
50
+ conversation.update(label: label_value)
51
+ "Label set to: #{label_value}"
52
+ rescue Botiasloop::Error => e
53
+ "Error setting label: #{e.message}"
54
+ end
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
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Botiasloop
6
+ module Commands
7
+ # New command - creates a new conversation
8
+ class New < Base
9
+ command :new
10
+ description "Start a new conversation"
11
+
12
+ def execute(context, _args = nil)
13
+ new_conversation = ConversationManager.create_new(context.user_id)
14
+ context.conversation = new_conversation
15
+
16
+ "**New conversation started (UUID: #{new_conversation.uuid}).**\n" \
17
+ "Use `/switch #{new_conversation.uuid}` to return later."
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Registry for slash commands
6
+ # Manages command registration, lookup, and execution
7
+ class Registry
8
+ # Regex pattern to match slash commands at the start of a message
9
+ # Captures command name and optional arguments
10
+ COMMAND_PATTERN = /^\/([a-zA-Z0-9_]+)(?:\s+(.+))?$/
11
+
12
+ def initialize
13
+ @commands = {}
14
+ end
15
+
16
+ # Register a command class
17
+ #
18
+ # @param command_class [Class] Command class inheriting from Base
19
+ # @raise [Error] If command class doesn't have a command_name
20
+ def register(command_class)
21
+ name = command_class.command_name
22
+ raise Error, "Command class must define command name" unless name
23
+
24
+ @commands[name] = command_class
25
+ end
26
+
27
+ # Get a command class by name
28
+ #
29
+ # @param name [Symbol] Command name
30
+ # @return [Class, nil] Command class or nil if not found
31
+ def [](name)
32
+ @commands[name]
33
+ end
34
+
35
+ # Get all registered command classes
36
+ #
37
+ # @return [Array<Class>] Array of command classes sorted by name
38
+ def all
39
+ @commands.values.sort_by(&:command_name)
40
+ end
41
+
42
+ # Get all registered command names
43
+ #
44
+ # @return [Array<Symbol>] Array of command names sorted
45
+ def names
46
+ @commands.keys.sort
47
+ end
48
+
49
+ # Clear all registered commands
50
+ # Useful for testing to prevent state leakage
51
+ def clear
52
+ @commands.clear
53
+ end
54
+
55
+ # Check if a message is a valid command
56
+ # Must start with / and be a registered command
57
+ #
58
+ # @param message [String] Message text to check
59
+ # @return [Boolean] True if message is a registered command
60
+ def command?(message)
61
+ return false unless message.is_a?(String)
62
+
63
+ match = message.match(COMMAND_PATTERN)
64
+ return false unless match
65
+
66
+ name = match[1]&.to_sym
67
+ @commands.key?(name)
68
+ end
69
+
70
+ # Execute a command from a message
71
+ #
72
+ # @param message [String] Full command message (e.g., "/help" or "/switch label")
73
+ # @param context [Context] Execution context
74
+ # @return [String] Command response or error message
75
+ def execute(message, context)
76
+ match = message.match(COMMAND_PATTERN)
77
+ return unknown_command_response(message) unless match
78
+
79
+ name = match[1]&.to_sym
80
+ args = match[2]
81
+
82
+ command_class = @commands[name]
83
+ return unknown_command_response(message) unless command_class
84
+
85
+ command = command_class.new
86
+ command.execute(context, args)
87
+ end
88
+
89
+ private
90
+
91
+ def unknown_command_response(message)
92
+ name = message.match(COMMAND_PATTERN)&.[](1) || "unknown"
93
+ "Unknown command: /#{name}. Type /help for available commands."
94
+ end
95
+ end
96
+
97
+ # Singleton registry instance
98
+ #
99
+ # @return [Registry] The global command registry
100
+ def self.registry
101
+ @registry ||= Registry.new
102
+ end
103
+
104
+ # Check if a message is a command
105
+ #
106
+ # @param message [String] Message to check
107
+ # @return [Boolean] True if message is a registered command
108
+ def self.command?(message)
109
+ registry.command?(message)
110
+ end
111
+
112
+ # Execute a command from a message
113
+ #
114
+ # @param message [String] Full command message
115
+ # @param context [Context] Execution context
116
+ # @return [String] Command response or error message
117
+ def self.execute(message, context)
118
+ registry.execute(message, context)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Reset command - clears conversation history
6
+ class Reset < Base
7
+ command :reset
8
+ description "Clear conversation history"
9
+
10
+ def execute(context, _args = nil)
11
+ conversation = context.conversation
12
+ conversation.reset!
13
+
14
+ "Conversation #{conversation.uuid} history and tokens cleared."
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Status command - shows current conversation status
6
+ class Status < Base
7
+ command :status
8
+ description "Show current conversation status"
9
+
10
+ def execute(context, _args = nil)
11
+ conversation = context.conversation
12
+ config = context.config
13
+
14
+ lines = ["**Conversation Status**"]
15
+ lines << "UUID: #{conversation.uuid}"
16
+ lines << "Label: #{format_label(conversation)}"
17
+ lines << "Model: #{config.providers["openrouter"]["model"]}"
18
+ lines << "Max iterations: #{config.max_iterations}"
19
+ lines << "Messages: #{conversation.history.length}"
20
+ lines << "Tokens: #{conversation.total_tokens} (#{conversation.input_tokens || 0} in / #{conversation.output_tokens || 0} out)"
21
+
22
+ lines.join("\n")
23
+ end
24
+
25
+ private
26
+
27
+ def format_label(conversation)
28
+ conversation.label? ? conversation.label : "(none - use /label <name> to set)"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # Switch command - switches to a different conversation by label or UUID
6
+ class Switch < Base
7
+ command :switch
8
+ description "Switch to a different conversation by label or UUID"
9
+
10
+ # Execute the switch command
11
+ #
12
+ # @param context [Context] Execution context
13
+ # @param args [String, nil] Label or UUID to switch to
14
+ # @return [String] Command response with conversation preview
15
+ def execute(context, args = nil)
16
+ identifier = args.to_s.strip
17
+
18
+ if identifier.empty?
19
+ return "Usage: /switch <label-or-uuid>"
20
+ end
21
+
22
+ new_conversation = ConversationManager.switch(context.user_id, identifier)
23
+ context.conversation = new_conversation
24
+
25
+ format_switch_response(new_conversation)
26
+ rescue Botiasloop::Error => e
27
+ "Error: #{e.message}"
28
+ end
29
+
30
+ private
31
+
32
+ def format_switch_response(conversation)
33
+ lines = ["**Conversation switched**"]
34
+ lines << "- UUID: #{conversation.uuid}"
35
+
36
+ lines << if conversation.label?
37
+ "- Label: #{conversation.label}"
38
+ else
39
+ "- Label: (no label)"
40
+ end
41
+
42
+ count = conversation.message_count
43
+ lines << "- Messages: #{count}"
44
+
45
+ last = conversation.last_activity
46
+ lines << if last
47
+ "- Last activity: #{format_time_ago(last)}"
48
+ else
49
+ "- Last activity: no activity"
50
+ end
51
+
52
+ lines.join("\n")
53
+ end
54
+
55
+ def format_time_ago(timestamp)
56
+ time = Time.parse(timestamp)
57
+ now = Time.now.utc
58
+ diff = now - time
59
+
60
+ if diff < 60
61
+ "just now"
62
+ elsif diff < 3600
63
+ "#{Integer(diff / 60)} minutes ago"
64
+ elsif diff < 86_400
65
+ "#{Integer(diff / 3600)} hours ago"
66
+ elsif diff < 604_800
67
+ "#{Integer(diff / 86_400)} days ago"
68
+ else
69
+ time.strftime("%Y-%m-%d %H:%M UTC")
70
+ end
71
+ rescue ArgumentError
72
+ timestamp
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Botiasloop
4
+ module Commands
5
+ # System prompt command - displays the current system prompt
6
+ class SystemPrompt < Base
7
+ command :systemprompt
8
+ description "Display the system prompt"
9
+
10
+ # Execute the systemprompt command
11
+ #
12
+ # @param context [Context] Execution context
13
+ # @param _args [String, nil] Unused - command takes no arguments
14
+ # @return [String] The system prompt
15
+ def execute(context, _args = nil)
16
+ context.conversation.system_prompt
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commands/context"
4
+ require_relative "commands/base"
5
+ require_relative "commands/registry"
6
+ require_relative "commands/help"
7
+ require_relative "commands/status"
8
+ require_relative "commands/reset"
9
+ require_relative "commands/new"
10
+ require_relative "commands/compact"
11
+ require_relative "commands/label"
12
+ require_relative "commands/conversations"
13
+ require_relative "commands/switch"
14
+ require_relative "commands/archive"
15
+ require_relative "commands/system_prompt"
16
+
17
+ module Botiasloop
18
+ # Slash commands module
19
+ # Provides a registry-based command system for bot control
20
+ module Commands
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway_config"
4
+
5
+ Anyway::Settings.default_config_path = ->(_) { File.expand_path("~/.config/botiasloop/config.yml") }
6
+
7
+ module Botiasloop
8
+ class Config < Anyway::Config
9
+ attr_config \
10
+ max_iterations: 20,
11
+ tools: {
12
+ web_search: {}
13
+ },
14
+ providers: {
15
+ openrouter: {
16
+ model: "moonshotai/kimi-k2.5"
17
+ }
18
+ },
19
+ channels: {
20
+ telegram: {
21
+ allowed_users: []
22
+ }
23
+ },
24
+ commands: {
25
+ summarize: {}
26
+ }
27
+
28
+ # Validation
29
+ required :providers
30
+
31
+ # Returns the first configured provider name and its config
32
+ # @return [Array<String, Hash>] provider name and config
33
+ def active_provider
34
+ providers.each do |name, config|
35
+ return [name.to_s, config] if provider_configured?(name, config)
36
+ end
37
+ ["openrouter", providers["openrouter"]]
38
+ end
39
+
40
+ private
41
+
42
+ def provider_configured?(name, config)
43
+ # Local providers need api_base
44
+ return true if %w[ollama gpustack].include?(name.to_s) && config["api_base"]
45
+
46
+ # Cloud providers need api_key
47
+ return true if config["api_key"]
48
+
49
+ # VertexAI needs project_id
50
+ return true if name.to_s == "vertexai" && config["project_id"]
51
+
52
+ # Azure needs api_base (and either api_key or ai_auth_token)
53
+ return true if name.to_s == "azure" && config["api_base"]
54
+
55
+ false
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Botiasloop
7
+ # Conversation model - represents a chat conversation with messages
8
+ # Direct Sequel model with all database and business logic combined
9
+ class Conversation < Sequel::Model(:conversations)
10
+ # Message model nested within Conversation namespace
11
+ class Message < Sequel::Model(:messages)
12
+ plugin :validation_helpers
13
+ plugin :timestamps, update_on_create: true
14
+
15
+ many_to_one :conversation, class: "Botiasloop::Conversation", key: :conversation_id
16
+
17
+ # Validations
18
+ def validate
19
+ super
20
+ validates_presence [:conversation_id, :role, :content]
21
+ end
22
+
23
+ # Convert message to hash for API compatibility
24
+ # @return [Hash] Message as hash with symbol keys
25
+ def to_hash
26
+ {
27
+ role: role,
28
+ content: content,
29
+ input_tokens: input_tokens || 0,
30
+ output_tokens: output_tokens || 0,
31
+ timestamp: timestamp.iso8601
32
+ }
33
+ end
34
+ end
35
+
36
+ one_to_many :messages, class: "Botiasloop::Conversation::Message", key: :conversation_id
37
+
38
+ # Set up validations and hooks
39
+ plugin :validation_helpers
40
+ plugin :timestamps, update_on_create: true
41
+
42
+ # Allow setting primary key (id) for UUID
43
+ unrestrict_primary_key
44
+
45
+ # Auto-generate UUID before creation if not provided
46
+ def before_create
47
+ self.id ||= SecureRandom.uuid
48
+ super
49
+ end
50
+
51
+ # Validations
52
+ def validate
53
+ super
54
+ validates_presence [:user_id]
55
+
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
60
+ end
61
+
62
+ # Check if this conversation has a label
63
+ #
64
+ # @return [Boolean] True if label is set
65
+ def label?
66
+ !label.nil? && !label.to_s.empty?
67
+ end
68
+
69
+ # Get the timestamp of the last activity in the conversation
70
+ #
71
+ # @return [String, nil] ISO8601 timestamp of last message, or nil if no messages
72
+ def last_activity
73
+ return nil if messages.empty?
74
+
75
+ messages_dataset.order(:timestamp).last.timestamp.utc.iso8601
76
+ end
77
+
78
+ # Get the number of messages in the conversation
79
+ #
80
+ # @return [Integer] Message count
81
+ def message_count
82
+ messages.count
83
+ end
84
+
85
+ # Get total tokens (input + output) for the conversation
86
+ #
87
+ # @return [Integer] Total token count
88
+ def total_tokens
89
+ (input_tokens || 0) + (output_tokens || 0)
90
+ end
91
+
92
+ # Add a message to the conversation
93
+ #
94
+ # @param role [String] Role of the message sender (user, assistant, system)
95
+ # @param content [String] Message content
96
+ # @param input_tokens [Integer] Input tokens for this message (prompt tokens sent to LLM)
97
+ # @param output_tokens [Integer] Output tokens for this message (completion tokens from LLM)
98
+ def add(role, content, input_tokens: 0, output_tokens: 0)
99
+ Message.create(
100
+ conversation_id: id,
101
+ role: role,
102
+ content: content,
103
+ input_tokens: input_tokens || 0,
104
+ output_tokens: output_tokens || 0,
105
+ timestamp: Time.now.utc
106
+ )
107
+
108
+ # Update conversation token totals
109
+ update_token_totals(input_tokens, output_tokens)
110
+ end
111
+
112
+ # Update conversation-level token totals
113
+ #
114
+ # @param input_tokens [Integer] Input tokens to add
115
+ # @param output_tokens [Integer] Output tokens to add
116
+ def update_token_totals(input_tokens, output_tokens)
117
+ self.input_tokens = (self.input_tokens || 0) + (input_tokens || 0)
118
+ self.output_tokens = (self.output_tokens || 0) + (output_tokens || 0)
119
+ save if modified?
120
+ end
121
+
122
+ # Get conversation history as array of message hashes
123
+ #
124
+ # @return [Array<Hash>] Array of message hashes with role, content, timestamp
125
+ def history
126
+ messages_dataset.order(:timestamp).map(&:to_hash)
127
+ end
128
+
129
+ # @return [String] UUID of the conversation
130
+ def uuid
131
+ # Return existing id or generate a new one for unsaved records
132
+ self.id ||= SecureRandom.uuid
133
+ end
134
+
135
+ # Reset conversation - clear all messages and reset token counts
136
+ def reset!
137
+ messages_dataset.delete
138
+ self.input_tokens = 0
139
+ self.output_tokens = 0
140
+ save
141
+ end
142
+
143
+ # Compact conversation by replacing old messages with a summary
144
+ #
145
+ # @param summary [String] Summary of older messages
146
+ # @param recent_messages [Array<Hash>] Recent messages to keep
147
+ def compact!(summary, recent_messages)
148
+ reset!
149
+ add("system", summary)
150
+ recent_messages.each do |msg|
151
+ add(msg[:role], msg[:content])
152
+ end
153
+ end
154
+
155
+ # Generate the system prompt for this conversation
156
+ # Includes current date/time and environment info
157
+ #
158
+ # @return [String] System prompt
159
+ def system_prompt
160
+ skills_registry = Skills::Registry.new
161
+
162
+ prompt = <<~PROMPT
163
+ You are Botias, an autonomous AI agent.
164
+
165
+ Environment:
166
+ - OS: #{RUBY_PLATFORM}
167
+ - Shell: #{ENV.fetch("SHELL", "unknown")}
168
+ - Working Directory: #{Dir.pwd}
169
+ - Date: #{Time.now.strftime("%Y-%m-%d")}
170
+ - Time: #{Time.now.strftime("%H:%M:%S %Z")}
171
+
172
+ You operate in a ReAct loop: Reason about the task, Act using tools, Observe results.
173
+ PROMPT
174
+
175
+ if skills_registry.skills.any?
176
+ prompt += <<~SKILLS
177
+
178
+ Available Skills:
179
+ #{skills_registry.skills_table}
180
+
181
+ To use a skill, read its SKILL.md file at the provided path using the shell tool (e.g., `cat ~/skills/skill-name/SKILL.md`).
182
+ Skills follow progressive disclosure: only metadata is shown above. Full instructions are loaded on demand.
183
+ SKILLS
184
+ end
185
+
186
+ prompt
187
+ end
188
+ end
189
+ end