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,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
# Manages conversation state globally, mapping user IDs to conversation UUIDs
|
|
5
|
+
# Handles all business logic: switching, finding current, labeling, etc.
|
|
6
|
+
class ConversationManager
|
|
7
|
+
# Valid label format: alphanumeric, dashes, and underscores
|
|
8
|
+
LABEL_REGEX = /\A[a-zA-Z0-9_-]+\z/
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Get or create the current conversation for a user
|
|
12
|
+
#
|
|
13
|
+
# @param user_id [String] User identifier
|
|
14
|
+
# @return [Conversation] Current conversation for the user
|
|
15
|
+
def current_for(user_id)
|
|
16
|
+
user_key = user_id.to_s
|
|
17
|
+
conversation = Conversation.where(user_id: user_key, is_current: true, archived: false).first
|
|
18
|
+
|
|
19
|
+
if conversation
|
|
20
|
+
Conversation[conversation.id]
|
|
21
|
+
else
|
|
22
|
+
create_new(user_key)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Switch a user to a different conversation by label or UUID
|
|
27
|
+
# Auto-unarchives archived conversations when switching to them
|
|
28
|
+
#
|
|
29
|
+
# @param user_id [String] User identifier
|
|
30
|
+
# @param identifier [String] Conversation label or UUID to switch to
|
|
31
|
+
# @return [Conversation] The switched-to conversation
|
|
32
|
+
# @raise [Error] If conversation with given identifier doesn't exist
|
|
33
|
+
def switch(user_id, identifier)
|
|
34
|
+
user_key = user_id.to_s
|
|
35
|
+
identifier = identifier.to_s.strip
|
|
36
|
+
|
|
37
|
+
raise Error, "Usage: /switch <label-or-uuid>" if identifier.empty?
|
|
38
|
+
|
|
39
|
+
# First try to find by label (include archived)
|
|
40
|
+
conversation = Conversation.where(user_id: user_key, label: identifier).first
|
|
41
|
+
|
|
42
|
+
# If not found by label, treat as UUID (include archived)
|
|
43
|
+
conversation ||= Conversation.find(id: identifier, user_id: user_key)
|
|
44
|
+
|
|
45
|
+
raise Error, "Conversation '#{identifier}' not found" unless conversation
|
|
46
|
+
|
|
47
|
+
# Auto-unarchive if switching to an archived conversation
|
|
48
|
+
conversation.update(archived: false) if conversation.archived
|
|
49
|
+
|
|
50
|
+
# Clear current flag from all user's conversations
|
|
51
|
+
Conversation.where(user_id: user_key).update(is_current: false)
|
|
52
|
+
|
|
53
|
+
# Set new conversation as current
|
|
54
|
+
conversation.update(is_current: true)
|
|
55
|
+
Conversation[conversation.id]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Create a new conversation and switch the user to it
|
|
59
|
+
#
|
|
60
|
+
# @param user_id [String] User identifier
|
|
61
|
+
# @return [Conversation] The newly created conversation
|
|
62
|
+
def create_new(user_id)
|
|
63
|
+
user_key = user_id.to_s
|
|
64
|
+
|
|
65
|
+
# Clear current flag from all user's conversations
|
|
66
|
+
Conversation.where(user_id: user_key).update(is_current: false)
|
|
67
|
+
|
|
68
|
+
# Create new conversation as current
|
|
69
|
+
conversation = Conversation.create(user_id: user_key, is_current: true)
|
|
70
|
+
Conversation[conversation.id]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get the UUID for a user's current conversation
|
|
74
|
+
#
|
|
75
|
+
# @param user_id [String] User identifier
|
|
76
|
+
# @return [String, nil] Current conversation UUID or nil if none exists
|
|
77
|
+
def current_uuid_for(user_id)
|
|
78
|
+
conversation = Conversation.where(user_id: user_id.to_s, is_current: true).first
|
|
79
|
+
conversation&.id
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# List all conversation mappings (excluding archived by default)
|
|
83
|
+
#
|
|
84
|
+
# @param include_archived [Boolean] Whether to include archived conversations
|
|
85
|
+
# @return [Hash] Hash mapping UUIDs to {user_id, label} hashes
|
|
86
|
+
def all_mappings(include_archived: false)
|
|
87
|
+
dataset = include_archived ? Conversation.dataset : Conversation.where(archived: false)
|
|
88
|
+
dataset.all.map do |conv|
|
|
89
|
+
[conv.id, {"user_id" => conv.user_id, "label" => conv.label}]
|
|
90
|
+
end.to_h
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Remove a user's current conversation
|
|
94
|
+
#
|
|
95
|
+
# @param user_id [String] User identifier
|
|
96
|
+
def remove(user_id)
|
|
97
|
+
conversation = Conversation.where(user_id: user_id.to_s, is_current: true).first
|
|
98
|
+
return unless conversation
|
|
99
|
+
|
|
100
|
+
conversation.destroy
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Clear all conversations (use with caution)
|
|
104
|
+
def clear_all
|
|
105
|
+
Conversation.db[:conversations].delete
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the label for a conversation
|
|
109
|
+
#
|
|
110
|
+
# @param uuid [String] Conversation UUID
|
|
111
|
+
# @return [String, nil] Label value or nil
|
|
112
|
+
def label(uuid)
|
|
113
|
+
conversation = Conversation.find(id: uuid)
|
|
114
|
+
conversation&.label
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Set the label for a conversation
|
|
118
|
+
#
|
|
119
|
+
# @param uuid [String] Conversation UUID
|
|
120
|
+
# @param value [String] Label value
|
|
121
|
+
# @return [String] The label value
|
|
122
|
+
# @raise [Error] If label format is invalid or already in use
|
|
123
|
+
def set_label(uuid, value)
|
|
124
|
+
conversation = Conversation.find(id: uuid)
|
|
125
|
+
raise Error, "Conversation not found" unless conversation
|
|
126
|
+
|
|
127
|
+
# Validate label format
|
|
128
|
+
unless value.nil? || value.to_s.empty? || value.to_s.match?(LABEL_REGEX)
|
|
129
|
+
raise Error, "Invalid label format. Use only letters, numbers, dashes, and underscores."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check uniqueness per user (excluding current conversation)
|
|
133
|
+
user_id = conversation.user_id
|
|
134
|
+
if value && !value.to_s.empty? && label_exists?(user_id, value, exclude_uuid: uuid)
|
|
135
|
+
raise Error, "Label '#{value}' already in use by another conversation"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Allow empty string to be treated as nil (clearing the label)
|
|
139
|
+
value = nil if value.to_s.empty?
|
|
140
|
+
|
|
141
|
+
Conversation.where(id: uuid).update(label: value)
|
|
142
|
+
value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check if a label exists for a user
|
|
146
|
+
#
|
|
147
|
+
# @param user_id [String] User identifier
|
|
148
|
+
# @param label [String] Label to check
|
|
149
|
+
# @param exclude_uuid [String, nil] UUID to exclude from check
|
|
150
|
+
# @return [Boolean] True if label exists for user
|
|
151
|
+
def label_exists?(user_id, label, exclude_uuid: nil)
|
|
152
|
+
return false unless label
|
|
153
|
+
|
|
154
|
+
query = Conversation.where(user_id: user_id.to_s, label: label)
|
|
155
|
+
query = query.exclude(id: exclude_uuid) if exclude_uuid
|
|
156
|
+
query.count > 0
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# List all conversations for a user
|
|
160
|
+
# Sorted by updated_at in descending order (most recently updated first)
|
|
161
|
+
#
|
|
162
|
+
# @param user_id [String] User identifier
|
|
163
|
+
# @param archived [Boolean, nil] Filter by archived status (nil = all, true = archived only, false = unarchived only)
|
|
164
|
+
# @return [Array<Hash>] Array of {uuid, label, updated_at} hashes
|
|
165
|
+
def list_by_user(user_id, archived: false)
|
|
166
|
+
dataset = Conversation.where(user_id: user_id.to_s)
|
|
167
|
+
dataset = dataset.where(archived: archived) unless archived.nil?
|
|
168
|
+
dataset.order(Sequel.desc(:updated_at)).all.map do |conv|
|
|
169
|
+
{uuid: conv.id, label: conv.label, updated_at: conv.updated_at}
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Find conversation UUID by label for a user
|
|
174
|
+
#
|
|
175
|
+
# @param user_id [String] User identifier
|
|
176
|
+
# @param label [String] Label to search for
|
|
177
|
+
# @return [String, nil] UUID or nil if not found
|
|
178
|
+
def find_by_label(user_id, label)
|
|
179
|
+
conversation = Conversation.where(user_id: user_id.to_s, label: label).first
|
|
180
|
+
conversation&.id
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Archive a conversation by label or UUID, or archive current if no identifier given
|
|
184
|
+
# When archiving current conversation, automatically creates a new one
|
|
185
|
+
#
|
|
186
|
+
# @param user_id [String] User identifier
|
|
187
|
+
# @param identifier [String, nil] Conversation label or UUID to archive (nil = archive current)
|
|
188
|
+
# @return [Hash] Hash with :archived and :new_conversation keys
|
|
189
|
+
# @raise [Error] If conversation not found
|
|
190
|
+
def archive(user_id, identifier = nil)
|
|
191
|
+
user_key = user_id.to_s
|
|
192
|
+
identifier = identifier.to_s.strip
|
|
193
|
+
|
|
194
|
+
if identifier.empty?
|
|
195
|
+
# Archive current conversation
|
|
196
|
+
conversation = Conversation.where(user_id: user_key, is_current: true).first
|
|
197
|
+
raise Error, "No current conversation to archive" unless conversation
|
|
198
|
+
|
|
199
|
+
# Archive the current conversation
|
|
200
|
+
conversation.update(archived: true, is_current: false)
|
|
201
|
+
|
|
202
|
+
# Create a new conversation (becomes current)
|
|
203
|
+
new_conversation = create_new(user_key)
|
|
204
|
+
|
|
205
|
+
{
|
|
206
|
+
archived: Conversation[conversation.id],
|
|
207
|
+
new_conversation: new_conversation
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
# Archive by label or UUID
|
|
211
|
+
conversation = Conversation.where(user_id: user_key, label: identifier).first
|
|
212
|
+
conversation ||= Conversation.find(id: identifier, user_id: user_key)
|
|
213
|
+
|
|
214
|
+
raise Error, "Conversation '#{identifier}' not found" unless conversation
|
|
215
|
+
|
|
216
|
+
# Cannot archive current conversation (must use archive without args)
|
|
217
|
+
raise Error, "Cannot archive the current conversation. Use /archive without arguments to archive current and start new." if conversation.is_current
|
|
218
|
+
|
|
219
|
+
conversation.update(archived: true, is_current: false)
|
|
220
|
+
{archived: Conversation[conversation.id]}
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Botiasloop
|
|
7
|
+
# Database connection and schema management for SQLite
|
|
8
|
+
class Database
|
|
9
|
+
# Default database path
|
|
10
|
+
DEFAULT_PATH = File.expand_path("~/.config/botiasloop/db.sqlite")
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Get or create database connection
|
|
14
|
+
# Automatically sets up schema on first connection
|
|
15
|
+
#
|
|
16
|
+
# @return [Sequel::SQLite::Database]
|
|
17
|
+
def connect
|
|
18
|
+
@db ||= begin
|
|
19
|
+
db = Sequel.sqlite(DEFAULT_PATH)
|
|
20
|
+
setup_schema!(db)
|
|
21
|
+
db
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Set up database schema
|
|
26
|
+
# Creates tables if they don't exist
|
|
27
|
+
def setup!
|
|
28
|
+
db = @db || connect
|
|
29
|
+
setup_schema!(db)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reset database - delete all data
|
|
33
|
+
def reset!
|
|
34
|
+
db = connect
|
|
35
|
+
db[:messages].delete if db.table_exists?(:messages)
|
|
36
|
+
db[:conversations].delete if db.table_exists?(:conversations)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Close database connection
|
|
40
|
+
def disconnect
|
|
41
|
+
@db&.disconnect
|
|
42
|
+
@db = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Set up database schema on a connection
|
|
48
|
+
# Creates tables if they don't exist
|
|
49
|
+
#
|
|
50
|
+
# @param db [Sequel::SQLite::Database] Database connection
|
|
51
|
+
def setup_schema!(db)
|
|
52
|
+
# Ensure directory exists
|
|
53
|
+
FileUtils.mkdir_p(File.dirname(DEFAULT_PATH))
|
|
54
|
+
|
|
55
|
+
# Create conversations table
|
|
56
|
+
db.create_table?(:conversations) do
|
|
57
|
+
String :id, primary_key: true
|
|
58
|
+
String :user_id, null: false
|
|
59
|
+
String :label
|
|
60
|
+
TrueClass :is_current, default: false
|
|
61
|
+
TrueClass :archived, default: false
|
|
62
|
+
Integer :input_tokens, default: 0
|
|
63
|
+
Integer :output_tokens, default: 0
|
|
64
|
+
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
|
|
65
|
+
DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
|
|
66
|
+
|
|
67
|
+
index [:user_id, :label], unique: true
|
|
68
|
+
index [:user_id, :archived]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create messages table
|
|
72
|
+
db.create_table?(:messages) do
|
|
73
|
+
primary_key :id
|
|
74
|
+
String :conversation_id, null: false
|
|
75
|
+
String :role, null: false
|
|
76
|
+
String :content, null: false, text: true
|
|
77
|
+
Integer :input_tokens, default: 0
|
|
78
|
+
Integer :output_tokens, default: 0
|
|
79
|
+
DateTime :timestamp, default: Sequel::CURRENT_TIMESTAMP
|
|
80
|
+
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
|
|
81
|
+
|
|
82
|
+
foreign_key [:conversation_id], :conversations, on_delete: :cascade
|
|
83
|
+
index [:conversation_id]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Establish database connection when models are loaded
|
|
91
|
+
# This ensures Sequel models have a valid database connection
|
|
92
|
+
Botiasloop::Database.connect
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Botiasloop
|
|
7
|
+
class Loop
|
|
8
|
+
MAX_TOOL_RETRIES = 3
|
|
9
|
+
|
|
10
|
+
# Initialize the ReAct loop
|
|
11
|
+
#
|
|
12
|
+
# @param provider [RubyLLM::Provider] Provider instance
|
|
13
|
+
# @param model [RubyLLM::Model] Model instance
|
|
14
|
+
# @param registry [Tools::Registry] Tool registry
|
|
15
|
+
# @param max_iterations [Integer] Maximum ReAct iterations
|
|
16
|
+
def initialize(provider, model, registry, max_iterations: 20)
|
|
17
|
+
@provider = provider
|
|
18
|
+
@model = model
|
|
19
|
+
@registry = registry
|
|
20
|
+
@max_iterations = max_iterations
|
|
21
|
+
@logger = Logger.new($stderr)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Run the ReAct loop
|
|
25
|
+
#
|
|
26
|
+
# @param conversation [Conversation] Conversation instance
|
|
27
|
+
# @param user_input [String] User input
|
|
28
|
+
# @return [String] Final response
|
|
29
|
+
# @raise [Error] If max iterations exceeded
|
|
30
|
+
def run(conversation, user_input)
|
|
31
|
+
conversation.add("user", user_input)
|
|
32
|
+
messages = build_messages(conversation)
|
|
33
|
+
|
|
34
|
+
# Track accumulated tokens across all iterations
|
|
35
|
+
total_input_tokens = 0
|
|
36
|
+
total_output_tokens = 0
|
|
37
|
+
|
|
38
|
+
@max_iterations.times do
|
|
39
|
+
response = iterate(messages)
|
|
40
|
+
|
|
41
|
+
# Accumulate tokens from this response
|
|
42
|
+
total_input_tokens += response.input_tokens || 0
|
|
43
|
+
total_output_tokens += response.output_tokens || 0
|
|
44
|
+
|
|
45
|
+
if response.tool_call?
|
|
46
|
+
# Add the assistant's message with tool_calls first
|
|
47
|
+
messages << response
|
|
48
|
+
|
|
49
|
+
response.tool_calls.each_value do |tool_call|
|
|
50
|
+
observation = execute_tool(tool_call)
|
|
51
|
+
messages << build_tool_result_message(tool_call.id, observation)
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
conversation.add("assistant", response.content, input_tokens: total_input_tokens, output_tokens: total_output_tokens)
|
|
55
|
+
return response.content
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
raise MaxIterationsExceeded.new(@max_iterations)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def build_messages(conversation)
|
|
65
|
+
system_prompt = [RubyLLM::Message.new(
|
|
66
|
+
role: :system,
|
|
67
|
+
content: conversation.system_prompt
|
|
68
|
+
)]
|
|
69
|
+
|
|
70
|
+
system_prompt + conversation.history.map do |msg|
|
|
71
|
+
role = msg[:role] || msg["role"]
|
|
72
|
+
content = msg[:content] || msg["content"]
|
|
73
|
+
RubyLLM::Message.new(
|
|
74
|
+
role: role.to_sym,
|
|
75
|
+
content: content
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def iterate(messages)
|
|
81
|
+
tool_schemas = @registry.schemas
|
|
82
|
+
@provider.complete(
|
|
83
|
+
messages,
|
|
84
|
+
tools: tool_schemas,
|
|
85
|
+
temperature: nil,
|
|
86
|
+
model: @model
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_tool_result_message(tool_call_id, content)
|
|
91
|
+
RubyLLM::Message.new(
|
|
92
|
+
role: :tool,
|
|
93
|
+
content: content,
|
|
94
|
+
tool_call_id: tool_call_id
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def execute_tool(tool_call)
|
|
99
|
+
@logger.info "[Tool] Executing #{tool_call.name} with arguments: #{tool_call.arguments}"
|
|
100
|
+
retries = 0
|
|
101
|
+
begin
|
|
102
|
+
result = @registry.execute(tool_call.name, tool_call.arguments)
|
|
103
|
+
build_observation(result)
|
|
104
|
+
rescue Error => e
|
|
105
|
+
retries += 1
|
|
106
|
+
retry if retries < MAX_TOOL_RETRIES
|
|
107
|
+
"Error: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_observation(result)
|
|
112
|
+
result.to_s
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
module Skills
|
|
5
|
+
# Discovers and loads skills from gem and user directories
|
|
6
|
+
class Loader
|
|
7
|
+
# @return [Array<Skill>] All loaded skills (default + user)
|
|
8
|
+
def self.load_all_skills
|
|
9
|
+
load_default_skills + load_user_skills
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [Array<Skill>] Skills shipped with the gem
|
|
13
|
+
def self.load_default_skills
|
|
14
|
+
skills_dir = File.join(Botiasloop.root, "data", "skills")
|
|
15
|
+
load_from_directory(skills_dir)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Array<Skill>] Skills from user's ~/skills/ directory
|
|
19
|
+
def self.load_user_skills
|
|
20
|
+
user_skills_dir = File.expand_path("~/skills")
|
|
21
|
+
load_from_directory(user_skills_dir)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Load skills from a specific directory
|
|
25
|
+
# @param dir [String] Directory path containing skill subdirectories
|
|
26
|
+
# @return [Array<Skill>]
|
|
27
|
+
def self.load_from_directory(dir)
|
|
28
|
+
return [] unless File.directory?(dir)
|
|
29
|
+
|
|
30
|
+
skills = []
|
|
31
|
+
Dir.entries(dir).each do |entry|
|
|
32
|
+
next if entry == "." || entry == ".."
|
|
33
|
+
|
|
34
|
+
skill_path = File.join(dir, entry)
|
|
35
|
+
next unless File.directory?(skill_path)
|
|
36
|
+
|
|
37
|
+
skill_md_path = File.join(skill_path, "SKILL.md")
|
|
38
|
+
next unless File.exist?(skill_md_path)
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
skills << Skill.new(skill_path)
|
|
42
|
+
rescue Error => e
|
|
43
|
+
warn "Failed to load skill from #{skill_path}: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
skills
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Find a specific skill by name
|
|
51
|
+
# @param name [String] Skill name
|
|
52
|
+
# @return [Skill, nil]
|
|
53
|
+
def self.find_by_name(name)
|
|
54
|
+
load_all_skills.find { |skill| skill.name == name }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "skill"
|
|
4
|
+
require_relative "loader"
|
|
5
|
+
|
|
6
|
+
module Botiasloop
|
|
7
|
+
module Skills
|
|
8
|
+
# Registry for managing available skills
|
|
9
|
+
class Registry
|
|
10
|
+
attr_reader :skills
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@skills = Loader.load_all_skills
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get all skills as formatted table for system prompt
|
|
17
|
+
# @return [String]
|
|
18
|
+
def skills_table
|
|
19
|
+
return "No skills available." if @skills.empty?
|
|
20
|
+
|
|
21
|
+
header = "| Skill Name | Description | Path |"
|
|
22
|
+
separator = "|------------|-------------|------|"
|
|
23
|
+
rows = @skills.map { |skill| "| #{skill.name} | #{skill.description} | #{skill.path} |" }
|
|
24
|
+
|
|
25
|
+
[header, separator, *rows].join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Find a skill by name
|
|
29
|
+
# @param name [String]
|
|
30
|
+
# @return [Skill, nil]
|
|
31
|
+
def find(name)
|
|
32
|
+
@skills.find { |skill| skill.name == name }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get all skill names
|
|
36
|
+
# @return [Array<String>]
|
|
37
|
+
def names
|
|
38
|
+
@skills.map(&:name)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
module Skills
|
|
5
|
+
# Represents a skill loaded from a SKILL.md file
|
|
6
|
+
# Follows the agentskills.io specification
|
|
7
|
+
class Skill
|
|
8
|
+
attr_reader :name, :description, :path, :metadata, :license, :compatibility, :skill_md_path, :body
|
|
9
|
+
|
|
10
|
+
# @param path [String] Path to skill directory containing SKILL.md
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = File.expand_path(path)
|
|
13
|
+
@skill_md_path = File.join(@path, "SKILL.md")
|
|
14
|
+
|
|
15
|
+
raise Error, "Skill not found: #{@skill_md_path}" unless File.exist?(@skill_md_path)
|
|
16
|
+
|
|
17
|
+
parse_skill_md
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [String] Full content of SKILL.md
|
|
21
|
+
def content
|
|
22
|
+
@content ||= File.read(@skill_md_path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_skill_md
|
|
28
|
+
file_content = content
|
|
29
|
+
|
|
30
|
+
if file_content =~ /^---\s*$
|
|
31
|
+
?(.*?)\n^---\s*$
|
|
32
|
+
?(.*)$/m
|
|
33
|
+
frontmatter = ::Regexp.last_match(1)
|
|
34
|
+
@body = ::Regexp.last_match(2).strip
|
|
35
|
+
else
|
|
36
|
+
raise Error, "Invalid SKILL.md format (missing frontmatter): #{@skill_md_path}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
metadata = parse_frontmatter(frontmatter)
|
|
40
|
+
|
|
41
|
+
@name = metadata["name"]
|
|
42
|
+
@description = metadata["description"]
|
|
43
|
+
@license = metadata["license"]
|
|
44
|
+
@compatibility = metadata["compatibility"]
|
|
45
|
+
@metadata = metadata["metadata"] || {}
|
|
46
|
+
|
|
47
|
+
validate!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_frontmatter(frontmatter)
|
|
51
|
+
require "yaml"
|
|
52
|
+
YAML.safe_load(frontmatter, permitted_classes: [Date, Time])
|
|
53
|
+
rescue Psych::SyntaxError => e
|
|
54
|
+
raise Error, "Invalid YAML frontmatter in #{@skill_md_path}: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate!
|
|
58
|
+
raise Error, "Missing 'name' in skill frontmatter: #{@skill_md_path}" if @name.nil? || @name.empty?
|
|
59
|
+
raise Error, "Missing 'description' in skill frontmatter: #{@skill_md_path}" if @description.nil? || @description.empty?
|
|
60
|
+
|
|
61
|
+
validate_name_format!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_name_format!
|
|
65
|
+
# Max 64 characters, lowercase alphanumeric and hyphens only
|
|
66
|
+
# Must not start or end with hyphen, no consecutive hyphens
|
|
67
|
+
unless @name =~ /\A[a-z0-9]+(-[a-z0-9]+)*\z/ && @name.length <= 64
|
|
68
|
+
raise Error, "Invalid skill name '#{@name}' in #{@skill_md_path}. " \
|
|
69
|
+
"Must be 1-64 chars, lowercase alphanumeric and hyphens only, " \
|
|
70
|
+
"no leading/trailing/consecutive hyphens."
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|