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,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
module Channels
|
|
5
|
+
# Registry for channel classes
|
|
6
|
+
#
|
|
7
|
+
# The Registry maintains a static mapping of channel identifiers to
|
|
8
|
+
# channel classes. It does not manage runtime instances - that is handled
|
|
9
|
+
# by ChannelsManager.
|
|
10
|
+
#
|
|
11
|
+
class Registry
|
|
12
|
+
attr_reader :channels
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@channels = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Register a channel class
|
|
19
|
+
#
|
|
20
|
+
# @param channel_class [Class] Channel class inheriting from Base
|
|
21
|
+
def register(channel_class)
|
|
22
|
+
identifier = channel_class.channel_identifier
|
|
23
|
+
raise Error, "Channel class must define channel_name" unless identifier
|
|
24
|
+
|
|
25
|
+
@channels[identifier] = channel_class
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Deregister a channel by name
|
|
29
|
+
#
|
|
30
|
+
# @param name [Symbol] Channel identifier
|
|
31
|
+
def deregister(name)
|
|
32
|
+
@channels.delete(name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get channel class by name
|
|
36
|
+
#
|
|
37
|
+
# @param name [Symbol] Channel identifier
|
|
38
|
+
# @return [Class, nil] Channel class or nil if not found
|
|
39
|
+
def [](name)
|
|
40
|
+
@channels[name]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get all registered channel names
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<Symbol>] Channel identifiers
|
|
46
|
+
def names
|
|
47
|
+
@channels.keys
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Clear all registered channels
|
|
51
|
+
# Useful for testing to prevent state leakage
|
|
52
|
+
def clear
|
|
53
|
+
@channels.clear
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Singleton registry instance
|
|
58
|
+
#
|
|
59
|
+
# @return [Registry] The global channel registry
|
|
60
|
+
def self.registry
|
|
61
|
+
@registry ||= Registry.new
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Botiasloop
|
|
6
|
+
# Manages concurrent execution of multiple channels
|
|
7
|
+
#
|
|
8
|
+
# ChannelsManager provides a higher-level abstraction over the Channel Registry,
|
|
9
|
+
# handling the threading and lifecycle management required to run multiple
|
|
10
|
+
# channels simultaneously. Each channel runs in its own thread, allowing
|
|
11
|
+
# independent operation and error isolation.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# config = Botiasloop::Config.load
|
|
15
|
+
# manager = Botiasloop::ChannelsManager.new(config)
|
|
16
|
+
# manager.start_channels.wait
|
|
17
|
+
#
|
|
18
|
+
class ChannelsManager
|
|
19
|
+
# Time to wait for graceful shutdown before force-killing threads
|
|
20
|
+
SHUTDOWN_TIMEOUT = 5
|
|
21
|
+
|
|
22
|
+
# Channels that should not be auto-started (interactive channels)
|
|
23
|
+
EXCLUDED_CHANNELS = %i[cli].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :config
|
|
26
|
+
|
|
27
|
+
# Initialize a new ChannelsManager
|
|
28
|
+
#
|
|
29
|
+
# @param config [Config] Configuration instance
|
|
30
|
+
def initialize(config)
|
|
31
|
+
@config = config
|
|
32
|
+
@logger = Logger.new($stdout)
|
|
33
|
+
@threads = {}
|
|
34
|
+
@instances = {}
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@running = false
|
|
37
|
+
@shutdown_requested = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Start all configured channels in separate threads
|
|
41
|
+
#
|
|
42
|
+
# Each channel is spawned in its own thread, allowing concurrent
|
|
43
|
+
# operation. Channels with missing configuration are skipped with
|
|
44
|
+
# a warning. Startup failures are logged but don't prevent other
|
|
45
|
+
# channels from starting.
|
|
46
|
+
#
|
|
47
|
+
# @return [ChannelsManager] self for method chaining
|
|
48
|
+
# @raise [Error] If channels are already running
|
|
49
|
+
def start_channels
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
raise Error, "Channels are already running" if @running
|
|
52
|
+
|
|
53
|
+
@running = true
|
|
54
|
+
@shutdown_requested = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
setup_signal_handlers
|
|
58
|
+
|
|
59
|
+
registry = Channels.registry
|
|
60
|
+
registry.channels.each do |identifier, channel_class|
|
|
61
|
+
next if EXCLUDED_CHANNELS.include?(identifier)
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
instance = channel_class.new(@config)
|
|
65
|
+
rescue Error => e
|
|
66
|
+
if e.message.match?(/Missing required configuration/)
|
|
67
|
+
@logger.warn "[ChannelsManager] Skipping #{identifier}: #{e.message}"
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
raise
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
thread = spawn_channel_thread(identifier, instance)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@threads[identifier] = thread
|
|
76
|
+
@instances[identifier] = instance
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@logger.info "[ChannelsManager] Started #{identifier} in thread #{thread.object_id}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Monitor threads for crashes
|
|
83
|
+
spawn_monitor_thread unless @threads.empty?
|
|
84
|
+
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Stop all running channels gracefully
|
|
89
|
+
#
|
|
90
|
+
# Sends stop signal to all channel instances and waits for threads
|
|
91
|
+
# to complete. Force-kills threads that don't stop within timeout.
|
|
92
|
+
#
|
|
93
|
+
# @return [void]
|
|
94
|
+
def stop_all
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
return unless @running
|
|
97
|
+
|
|
98
|
+
@shutdown_requested = true
|
|
99
|
+
@running = false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@logger.info "[ChannelsManager] Stopping all channels..."
|
|
103
|
+
|
|
104
|
+
# Stop all channel instances
|
|
105
|
+
@instances.each do |identifier, instance|
|
|
106
|
+
instance.stop if instance.running?
|
|
107
|
+
rescue => e
|
|
108
|
+
@logger.error "[ChannelsManager] Error stopping #{identifier}: #{e.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Wait for threads to complete
|
|
112
|
+
@threads.each do |identifier, thread|
|
|
113
|
+
unless thread.join(SHUTDOWN_TIMEOUT)
|
|
114
|
+
@logger.warn "[ChannelsManager] Force-killing #{identifier} thread"
|
|
115
|
+
thread.kill
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
@threads.clear
|
|
121
|
+
@instances.clear
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@logger.info "[ChannelsManager] All channels stopped"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check if any channels are currently running
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean] True if any channel thread is alive
|
|
130
|
+
def running?
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
return false unless @running
|
|
133
|
+
|
|
134
|
+
@threads.any? { |_, thread| thread.alive? }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Get the number of active channel threads
|
|
139
|
+
#
|
|
140
|
+
# @return [Integer] Count of alive threads
|
|
141
|
+
def thread_count
|
|
142
|
+
@mutex.synchronize do
|
|
143
|
+
@threads.count { |_, thread| thread.alive? }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get a specific channel instance
|
|
148
|
+
#
|
|
149
|
+
# @param identifier [Symbol] Channel identifier
|
|
150
|
+
# @return [Base, nil] Channel instance or nil if not running
|
|
151
|
+
def instance(identifier)
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
@instances[identifier]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Get status information for a specific channel
|
|
158
|
+
#
|
|
159
|
+
# @param identifier [Symbol] Channel identifier
|
|
160
|
+
# @return [Hash, nil] Status hash or nil if channel not found
|
|
161
|
+
# * :identifier [Symbol] Channel identifier
|
|
162
|
+
# * :running [Boolean] Whether channel instance reports running
|
|
163
|
+
# * :thread_alive [Boolean] Whether thread is alive
|
|
164
|
+
# * :thread_id [Integer] Thread object ID
|
|
165
|
+
def channel_status(identifier)
|
|
166
|
+
@mutex.synchronize do
|
|
167
|
+
instance = @instances[identifier]
|
|
168
|
+
thread = @threads[identifier]
|
|
169
|
+
|
|
170
|
+
return nil unless instance
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
identifier: identifier,
|
|
174
|
+
running: instance.running?,
|
|
175
|
+
thread_alive: thread&.alive? || false,
|
|
176
|
+
thread_id: thread&.object_id
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Get status for all running channels
|
|
182
|
+
#
|
|
183
|
+
# @return [Hash{Symbol => Hash}] Map of channel identifier to status
|
|
184
|
+
def all_statuses
|
|
185
|
+
@mutex.synchronize do
|
|
186
|
+
@instances.transform_values do |instance|
|
|
187
|
+
thread = @threads[instance.class.channel_identifier]
|
|
188
|
+
{
|
|
189
|
+
identifier: instance.class.channel_identifier,
|
|
190
|
+
running: instance.running?,
|
|
191
|
+
thread_alive: thread&.alive? || false,
|
|
192
|
+
thread_id: thread&.object_id
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Block until all channels have stopped
|
|
199
|
+
#
|
|
200
|
+
# Useful for daemon mode where the main thread should wait.
|
|
201
|
+
# Returns immediately if no channels are running.
|
|
202
|
+
#
|
|
203
|
+
# @return [void]
|
|
204
|
+
def wait
|
|
205
|
+
return unless running?
|
|
206
|
+
|
|
207
|
+
# Wait for all threads to complete
|
|
208
|
+
loop do
|
|
209
|
+
sleep 0.1
|
|
210
|
+
break unless running?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Spawn a thread to run a channel
|
|
217
|
+
#
|
|
218
|
+
# @param identifier [Symbol] Channel identifier
|
|
219
|
+
# @param instance [Base] Channel instance
|
|
220
|
+
# @return [Thread] The spawned thread
|
|
221
|
+
def spawn_channel_thread(identifier, instance)
|
|
222
|
+
Thread.new do
|
|
223
|
+
Thread.current.name = "botiasloop-#{identifier}"
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
instance.start
|
|
227
|
+
rescue => e
|
|
228
|
+
@logger.error "[ChannelsManager] Channel #{identifier} crashed: #{e.message}"
|
|
229
|
+
@logger.error "[ChannelsManager] #{e.backtrace&.first(5)&.join("\n")}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Spawn a monitor thread to detect channel crashes
|
|
235
|
+
#
|
|
236
|
+
# @return [Thread] The monitor thread
|
|
237
|
+
def spawn_monitor_thread
|
|
238
|
+
Thread.new do
|
|
239
|
+
Thread.current.name = "botiasloop-monitor"
|
|
240
|
+
|
|
241
|
+
loop do
|
|
242
|
+
sleep 1.0
|
|
243
|
+
|
|
244
|
+
@mutex.synchronize do
|
|
245
|
+
break if @shutdown_requested
|
|
246
|
+
|
|
247
|
+
@threads.each do |identifier, thread|
|
|
248
|
+
next if thread.alive?
|
|
249
|
+
|
|
250
|
+
instance = @instances[identifier]
|
|
251
|
+
if instance&.running?
|
|
252
|
+
@logger.error "[ChannelsManager] Thread for #{identifier} died unexpectedly"
|
|
253
|
+
@instances.delete(identifier)
|
|
254
|
+
@threads.delete(identifier)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Exit monitor if no more threads
|
|
259
|
+
break if @threads.empty?
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Set up signal handlers for graceful shutdown
|
|
266
|
+
#
|
|
267
|
+
# @return [void]
|
|
268
|
+
def setup_signal_handlers
|
|
269
|
+
# Store original handlers
|
|
270
|
+
@original_int_handler = Signal.trap("INT") { handle_shutdown_signal("INT") }
|
|
271
|
+
@original_term_handler = Signal.trap("TERM") { handle_shutdown_signal("TERM") }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Handle shutdown signals
|
|
275
|
+
#
|
|
276
|
+
# Signals are handled in a separate thread to avoid
|
|
277
|
+
# limitations of trap context (no Mutex operations).
|
|
278
|
+
#
|
|
279
|
+
# @param signal [String] Signal name
|
|
280
|
+
# @return [void]
|
|
281
|
+
def handle_shutdown_signal(signal)
|
|
282
|
+
# Defer to separate thread to avoid trap context limitations
|
|
283
|
+
Thread.new do
|
|
284
|
+
@logger.info "[ChannelsManager] Received #{signal}, shutting down..."
|
|
285
|
+
stop_all
|
|
286
|
+
|
|
287
|
+
# Call original handler if it exists
|
|
288
|
+
case signal
|
|
289
|
+
when "INT"
|
|
290
|
+
@original_int_handler&.call if @original_int_handler.respond_to?(:call)
|
|
291
|
+
when "TERM"
|
|
292
|
+
@original_term_handler&.call if @original_term_handler.respond_to?(:call)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
exit(0)
|
|
296
|
+
end.join
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
module Commands
|
|
5
|
+
# Archive command - archives a conversation by label or UUID, or current conversation if no args
|
|
6
|
+
class Archive < Base
|
|
7
|
+
command :archive
|
|
8
|
+
description "Archive current conversation (no args) or a specific conversation by label/UUID"
|
|
9
|
+
|
|
10
|
+
# Execute the archive command
|
|
11
|
+
# Without args: archives current conversation and creates a new one
|
|
12
|
+
# With args: archives the specified conversation
|
|
13
|
+
#
|
|
14
|
+
# @param context [Context] Execution context
|
|
15
|
+
# @param args [String, nil] Label or UUID to archive (nil = archive current)
|
|
16
|
+
# @return [String] Command response with archived conversation details
|
|
17
|
+
def execute(context, args = nil)
|
|
18
|
+
identifier = args.to_s.strip
|
|
19
|
+
result = ConversationManager.archive(context.user_id, identifier.empty? ? nil : identifier)
|
|
20
|
+
|
|
21
|
+
if result[:new_conversation]
|
|
22
|
+
# Archived current and created new
|
|
23
|
+
context.conversation = result[:new_conversation]
|
|
24
|
+
format_archive_current_response(result[:archived], result[:new_conversation])
|
|
25
|
+
else
|
|
26
|
+
# Archived specific conversation
|
|
27
|
+
format_archive_response(result[:archived])
|
|
28
|
+
end
|
|
29
|
+
rescue Botiasloop::Error => e
|
|
30
|
+
"Error: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def format_archive_response(conversation)
|
|
36
|
+
lines = ["**Conversation archived successfully**"]
|
|
37
|
+
lines << "- UUID: #{conversation.uuid}"
|
|
38
|
+
|
|
39
|
+
lines << if conversation.label?
|
|
40
|
+
"- Label: #{conversation.label}"
|
|
41
|
+
else
|
|
42
|
+
"- Label: (no label)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
count = conversation.message_count
|
|
46
|
+
lines << "- Messages: #{count}"
|
|
47
|
+
|
|
48
|
+
last = conversation.last_activity
|
|
49
|
+
lines << if last
|
|
50
|
+
"- Last activity: #{format_time_ago(last)}"
|
|
51
|
+
else
|
|
52
|
+
"- Last activity: no activity"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lines.join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def format_archive_current_response(archived, new_conversation)
|
|
59
|
+
lines = ["**Current conversation archived and new conversation started**"]
|
|
60
|
+
lines << ""
|
|
61
|
+
lines << "Archived:"
|
|
62
|
+
lines << "- UUID: #{archived.uuid}"
|
|
63
|
+
|
|
64
|
+
lines << if archived.label?
|
|
65
|
+
"- Label: #{archived.label}"
|
|
66
|
+
else
|
|
67
|
+
"- Label: (no label)"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
count = archived.message_count
|
|
71
|
+
lines << "- Messages: #{count}"
|
|
72
|
+
|
|
73
|
+
last = archived.last_activity
|
|
74
|
+
lines << if last
|
|
75
|
+
"- Last activity: #{format_time_ago(last)}"
|
|
76
|
+
else
|
|
77
|
+
"- Last activity: no activity"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
lines << ""
|
|
81
|
+
lines << "New conversation:"
|
|
82
|
+
lines << "- UUID: #{new_conversation.uuid}"
|
|
83
|
+
lines << "- Label: (no label)"
|
|
84
|
+
|
|
85
|
+
lines.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def format_time_ago(timestamp)
|
|
89
|
+
time = Time.parse(timestamp)
|
|
90
|
+
now = Time.now.utc
|
|
91
|
+
diff = now - time
|
|
92
|
+
|
|
93
|
+
if diff < 60
|
|
94
|
+
"just now"
|
|
95
|
+
elsif diff < 3600
|
|
96
|
+
"#{Integer(diff / 60)} minutes ago"
|
|
97
|
+
elsif diff < 86_400
|
|
98
|
+
"#{Integer(diff / 3600)} hours ago"
|
|
99
|
+
elsif diff < 604_800
|
|
100
|
+
"#{Integer(diff / 86_400)} days ago"
|
|
101
|
+
else
|
|
102
|
+
time.strftime("%Y-%m-%d %H:%M UTC")
|
|
103
|
+
end
|
|
104
|
+
rescue ArgumentError
|
|
105
|
+
timestamp
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
module Commands
|
|
5
|
+
# Base class for all slash commands
|
|
6
|
+
# Provides DSL for defining command metadata
|
|
7
|
+
class Base
|
|
8
|
+
class << self
|
|
9
|
+
# Get or set the command name
|
|
10
|
+
# Automatically registers the command when name is set
|
|
11
|
+
#
|
|
12
|
+
# @param name [Symbol, nil] Command name to set
|
|
13
|
+
# @return [Symbol, nil] The command name
|
|
14
|
+
def command(name = nil)
|
|
15
|
+
if name
|
|
16
|
+
@command_name = name
|
|
17
|
+
# Auto-register when command name is set
|
|
18
|
+
Botiasloop::Commands.registry.register(self)
|
|
19
|
+
end
|
|
20
|
+
@command_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
alias_method :command_name, :command
|
|
24
|
+
|
|
25
|
+
# Get or set the command description
|
|
26
|
+
#
|
|
27
|
+
# @param text [String, nil] Description text to set
|
|
28
|
+
# @return [String, nil] The command description
|
|
29
|
+
def description(text = nil)
|
|
30
|
+
if text
|
|
31
|
+
@description = text
|
|
32
|
+
end
|
|
33
|
+
@description
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Called when a subclass is defined
|
|
37
|
+
# No-op - registration happens when command() is called
|
|
38
|
+
def inherited(subclass)
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Execute the command
|
|
44
|
+
#
|
|
45
|
+
# @param context [Context] Execution context
|
|
46
|
+
# @param args [String, nil] Command arguments
|
|
47
|
+
# @return [String] Command response
|
|
48
|
+
# @raise [NotImplementedError] Subclass must implement
|
|
49
|
+
def execute(context, args = nil)
|
|
50
|
+
raise NotImplementedError, "Subclass must implement #execute"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Botiasloop
|
|
6
|
+
module Commands
|
|
7
|
+
# Compact command - compresses conversation by summarizing older messages
|
|
8
|
+
class Compact < Base
|
|
9
|
+
KEEP_RECENT = 5
|
|
10
|
+
MIN_MESSAGES = 10
|
|
11
|
+
|
|
12
|
+
command :compact
|
|
13
|
+
description "Compress conversation by summarizing older messages"
|
|
14
|
+
|
|
15
|
+
def execute(context, _args = nil)
|
|
16
|
+
conversation = context.conversation
|
|
17
|
+
config = context.config
|
|
18
|
+
|
|
19
|
+
messages = conversation.history
|
|
20
|
+
|
|
21
|
+
if messages.length < MIN_MESSAGES
|
|
22
|
+
return "Need at least #{MIN_MESSAGES} messages to compact. Current: #{messages.length}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Split messages: older ones to summarize, recent ones to keep
|
|
26
|
+
older_messages = messages[0...-KEEP_RECENT]
|
|
27
|
+
recent_messages = messages.last(KEEP_RECENT)
|
|
28
|
+
|
|
29
|
+
# Generate summary using LLM
|
|
30
|
+
summary = summarize_messages(older_messages, config)
|
|
31
|
+
|
|
32
|
+
# Replace conversation history
|
|
33
|
+
conversation.compact!(summary, recent_messages)
|
|
34
|
+
|
|
35
|
+
compacted_count = older_messages.length
|
|
36
|
+
summary_preview = (summary.length > 100) ? "#{summary[0..100]}..." : summary
|
|
37
|
+
"Conversation #{conversation.uuid} compacted.\n" \
|
|
38
|
+
"#{compacted_count} messages summarized, #{recent_messages.length} recent messages kept.\n" \
|
|
39
|
+
"Summary: #{summary_preview}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def summarize_messages(messages, config)
|
|
45
|
+
chat = create_chat(config)
|
|
46
|
+
|
|
47
|
+
# Format messages for summarization
|
|
48
|
+
conversation_text = messages.map do |msg|
|
|
49
|
+
"#{msg[:role]}: #{msg[:content]}"
|
|
50
|
+
end.join("\n\n")
|
|
51
|
+
|
|
52
|
+
prompt = <<~PROMPT
|
|
53
|
+
Please summarize the following conversation, preserving key context, decisions, and facts. Be concise but comprehensive:
|
|
54
|
+
|
|
55
|
+
#{conversation_text}
|
|
56
|
+
PROMPT
|
|
57
|
+
|
|
58
|
+
chat.add_message(role: :user, content: prompt)
|
|
59
|
+
response = chat.complete
|
|
60
|
+
|
|
61
|
+
response.content
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create_chat(config)
|
|
65
|
+
summarize_config = config.commands["summarize"] || {}
|
|
66
|
+
|
|
67
|
+
if summarize_config["provider"] && summarize_config["model"]
|
|
68
|
+
# Use configured provider/model for summarization
|
|
69
|
+
RubyLLM.chat(model: summarize_config["model"])
|
|
70
|
+
else
|
|
71
|
+
# Fall back to default model
|
|
72
|
+
default_model = config.providers["openrouter"]["model"]
|
|
73
|
+
RubyLLM.chat(model: default_model)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
module Commands
|
|
5
|
+
# Context object passed to command executions
|
|
6
|
+
# Provides access to conversation, config, channel, and user info
|
|
7
|
+
class Context
|
|
8
|
+
# @return [Conversation] The current conversation
|
|
9
|
+
attr_accessor :conversation
|
|
10
|
+
|
|
11
|
+
# @return [Config] The bot configuration
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
# @return [Channels::Base, nil] The channel instance (nil in CLI)
|
|
15
|
+
attr_reader :channel
|
|
16
|
+
|
|
17
|
+
# @return [String, nil] The user/source identifier
|
|
18
|
+
attr_reader :user_id
|
|
19
|
+
|
|
20
|
+
# Initialize context
|
|
21
|
+
#
|
|
22
|
+
# @param conversation [Conversation] The current conversation
|
|
23
|
+
# @param config [Config] The bot configuration
|
|
24
|
+
# @param channel [Channels::Base, nil] The channel instance (nil in CLI)
|
|
25
|
+
# @param user_id [String, nil] The user/source identifier
|
|
26
|
+
def initialize(conversation:, config:, channel: nil, user_id: nil)
|
|
27
|
+
@conversation = conversation
|
|
28
|
+
@config = config
|
|
29
|
+
@channel = channel
|
|
30
|
+
@user_id = user_id
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Botiasloop
|
|
4
|
+
module Commands
|
|
5
|
+
# Conversations command - lists all conversations
|
|
6
|
+
class Conversations < Base
|
|
7
|
+
command :conversations
|
|
8
|
+
description "List all conversations (use '/conversations archived' to list archived)"
|
|
9
|
+
|
|
10
|
+
# Execute the conversations command
|
|
11
|
+
# Lists non-archived conversations by default, or archived conversations when specified
|
|
12
|
+
# Sorted by last updated (most recent first)
|
|
13
|
+
#
|
|
14
|
+
# @param context [Context] Execution context
|
|
15
|
+
# @param args [String, nil] Arguments - 'archived' to list archived conversations
|
|
16
|
+
# @return [String] Formatted list of conversations
|
|
17
|
+
def execute(context, args = nil)
|
|
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)
|
|
21
|
+
|
|
22
|
+
lines = show_archived ? ["**Archived Conversations**"] : ["**Conversations**"]
|
|
23
|
+
|
|
24
|
+
if conversations.empty?
|
|
25
|
+
lines << (show_archived ? "No archived conversations found." : "No conversations found.")
|
|
26
|
+
return lines.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
conversations.each do |conv|
|
|
30
|
+
prefix = (conv[:uuid] == current_uuid) ? "[current] " : ""
|
|
31
|
+
label = conv[:label]
|
|
32
|
+
suffix = label ? " (#{label})" : ""
|
|
33
|
+
lines << "#{prefix}#{conv[:uuid]}#{suffix}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|