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.
- checksums.yaml +7 -0
- data/README.md +343 -0
- data/bin/botiasloop +155 -0
- data/data/skills/skill-creator/SKILL.md +329 -0
- data/data/skills/skill-creator/assets/ruby_api_cli_template.rb +151 -0
- data/data/skills/skill-creator/references/specification.md +99 -0
- data/lib/botiasloop/agent.rb +112 -0
- data/lib/botiasloop/channels/base.rb +248 -0
- data/lib/botiasloop/channels/cli.rb +101 -0
- data/lib/botiasloop/channels/telegram.rb +348 -0
- data/lib/botiasloop/channels.rb +64 -0
- data/lib/botiasloop/channels_manager.rb +299 -0
- data/lib/botiasloop/commands/archive.rb +109 -0
- data/lib/botiasloop/commands/base.rb +54 -0
- data/lib/botiasloop/commands/compact.rb +78 -0
- data/lib/botiasloop/commands/context.rb +34 -0
- data/lib/botiasloop/commands/conversations.rb +40 -0
- data/lib/botiasloop/commands/help.rb +30 -0
- data/lib/botiasloop/commands/label.rb +64 -0
- data/lib/botiasloop/commands/new.rb +21 -0
- data/lib/botiasloop/commands/registry.rb +121 -0
- data/lib/botiasloop/commands/reset.rb +18 -0
- data/lib/botiasloop/commands/status.rb +32 -0
- data/lib/botiasloop/commands/switch.rb +76 -0
- data/lib/botiasloop/commands/system_prompt.rb +20 -0
- data/lib/botiasloop/commands.rb +22 -0
- data/lib/botiasloop/config.rb +58 -0
- data/lib/botiasloop/conversation.rb +189 -0
- data/lib/botiasloop/conversation_manager.rb +225 -0
- data/lib/botiasloop/database.rb +92 -0
- data/lib/botiasloop/loop.rb +115 -0
- data/lib/botiasloop/skills/loader.rb +58 -0
- data/lib/botiasloop/skills/registry.rb +42 -0
- data/lib/botiasloop/skills/skill.rb +75 -0
- data/lib/botiasloop/systemd_service.rb +300 -0
- data/lib/botiasloop/tool.rb +24 -0
- data/lib/botiasloop/tools/registry.rb +68 -0
- data/lib/botiasloop/tools/shell.rb +50 -0
- data/lib/botiasloop/tools/web_search.rb +64 -0
- data/lib/botiasloop/version.rb +5 -0
- data/lib/botiasloop.rb +45 -0
- 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
|