aia 0.9.22 → 0.9.24

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.
@@ -0,0 +1,283 @@
1
+ # lib/aia/directives/checkpoint.rb
2
+ #
3
+ # Checkpoint and restore directives for managing conversation state.
4
+ # Uses RubyLLM's Chat.@messages as the source of truth for conversation history.
5
+ #
6
+
7
+ module AIA
8
+ module Directives
9
+ module Checkpoint
10
+ # Module-level state for checkpoints
11
+ # Note: Using @checkpoint_store to avoid naming conflict with //checkpoints directive
12
+ @checkpoint_store = {}
13
+ @checkpoint_counter = 0
14
+ @last_checkpoint_name = nil
15
+
16
+ class << self
17
+ attr_accessor :checkpoint_store, :checkpoint_counter, :last_checkpoint_name
18
+
19
+ # Reset all checkpoint state (useful for testing)
20
+ def reset!
21
+ @checkpoint_store = {}
22
+ @checkpoint_counter = 0
23
+ @last_checkpoint_name = nil
24
+ end
25
+ end
26
+
27
+ # //checkpoint [name]
28
+ # Creates a named checkpoint of the current conversation state.
29
+ # If no name is provided, uses an auto-incrementing number.
30
+ def self.checkpoint(args, _unused = nil)
31
+ name = args.empty? ? nil : args.join(' ').strip
32
+
33
+ if name.nil? || name.empty?
34
+ self.checkpoint_counter += 1
35
+ name = checkpoint_counter.to_s
36
+ end
37
+
38
+ chats = get_chats
39
+ return "Error: No active chat sessions found." if chats.nil? || chats.empty?
40
+
41
+ # Deep copy messages from all chats
42
+ first_chat_messages = chats.values.first&.messages || []
43
+ checkpoint_store[name] = {
44
+ messages: chats.transform_values { |chat|
45
+ chat.messages.map { |msg| deep_copy_message(msg) }
46
+ },
47
+ position: first_chat_messages.size,
48
+ created_at: Time.now,
49
+ topic_preview: extract_last_user_message(first_chat_messages)
50
+ }
51
+ self.last_checkpoint_name = name
52
+
53
+ puts "Checkpoint '#{name}' created at position #{checkpoint_store[name][:position]}."
54
+ ""
55
+ end
56
+
57
+ # //restore [name]
58
+ # Restores the conversation state to a previously saved checkpoint.
59
+ # If no name is provided, restores to the previous checkpoint (one step back).
60
+ def self.restore(args, _unused = nil)
61
+ name = args.empty? ? nil : args.join(' ').strip
62
+
63
+ if name.nil? || name.empty?
64
+ # Find the previous checkpoint (second-to-last by position)
65
+ name = find_previous_checkpoint
66
+ if name.nil?
67
+ return "Error: No previous checkpoint to restore to."
68
+ end
69
+ end
70
+
71
+ unless checkpoint_store.key?(name)
72
+ available = checkpoint_names.empty? ? "none" : checkpoint_names.join(', ')
73
+ return "Error: Checkpoint '#{name}' not found. Available: #{available}"
74
+ end
75
+
76
+ checkpoint_data = checkpoint_store[name]
77
+ chats = get_chats
78
+
79
+ return "Error: No active chat sessions found." if chats.nil? || chats.empty?
80
+
81
+ # Restore messages to each chat
82
+ checkpoint_data[:messages].each do |model_id, saved_messages|
83
+ chat = chats[model_id]
84
+ next unless chat
85
+
86
+ # Replace the chat's messages with the saved ones
87
+ restored_messages = saved_messages.map { |msg| deep_copy_message(msg) }
88
+ chat.instance_variable_set(:@messages, restored_messages)
89
+ end
90
+
91
+ # Remove checkpoints that are now invalid (position > restored position)
92
+ # because they reference conversation states that no longer exist
93
+ restored_position = checkpoint_data[:position]
94
+ removed_count = remove_invalid_checkpoints(restored_position)
95
+
96
+ # Update last_checkpoint_name to this checkpoint
97
+ self.last_checkpoint_name = name
98
+
99
+ msg = "Context restored to checkpoint '#{name}' (position #{restored_position})."
100
+ msg += " Removed #{removed_count} checkpoint(s) that were beyond this position." if removed_count > 0
101
+ msg
102
+ end
103
+
104
+ # //clear
105
+ # Clears the conversation context, optionally keeping the system prompt.
106
+ def self.clear(args, _unused = nil)
107
+ keep_system = !args.include?('--all')
108
+
109
+ chats = get_chats
110
+ return "Error: No active chat sessions found." if chats.nil? || chats.empty?
111
+
112
+ chats.each do |_model_id, chat|
113
+ if keep_system
114
+ system_msg = chat.messages.find { |m| m.role == :system }
115
+ chat.instance_variable_set(:@messages, [])
116
+ chat.add_message(system_msg) if system_msg
117
+ else
118
+ chat.instance_variable_set(:@messages, [])
119
+ end
120
+ end
121
+
122
+ # Clear all checkpoints
123
+ checkpoint_store.clear
124
+ self.checkpoint_counter = 0
125
+ self.last_checkpoint_name = nil
126
+
127
+ "Chat context cleared."
128
+ end
129
+
130
+ # //review
131
+ # Displays the current conversation context with checkpoint markers.
132
+ def self.review(args, _unused = nil)
133
+ chats = get_chats
134
+ return "Error: No active chat sessions found." if chats.nil? || chats.empty?
135
+
136
+ # For multi-model, show first chat's messages (they should be similar for user messages)
137
+ first_chat = chats.values.first
138
+ messages = first_chat&.messages || []
139
+
140
+ puts "\n=== Chat Context (RubyLLM) ==="
141
+ puts "Total messages: #{messages.size}"
142
+ puts "Models: #{chats.keys.join(', ')}"
143
+ puts "Checkpoints: #{checkpoint_names.join(', ')}" if checkpoint_names.any?
144
+ puts
145
+
146
+ positions = checkpoint_positions
147
+
148
+ messages.each_with_index do |msg, index|
149
+ # Show checkpoint marker if one exists at this position
150
+ if positions[index]
151
+ puts "📍 [Checkpoint: #{positions[index].join(', ')}]"
152
+ puts "-" * 40
153
+ end
154
+
155
+ role = msg.role.to_s.capitalize
156
+ content = format_message_content(msg)
157
+
158
+ puts "#{index + 1}. [#{role}]: #{content}"
159
+ puts
160
+ end
161
+
162
+ # Check for checkpoint at the end
163
+ if positions[messages.size]
164
+ puts "📍 [Checkpoint: #{positions[messages.size].join(', ')}]"
165
+ puts "-" * 40
166
+ end
167
+
168
+ puts "=== End of Context ==="
169
+ ""
170
+ end
171
+
172
+ # //checkpoints
173
+ # Lists all available checkpoints with their details.
174
+ def self.checkpoints_list(args, _unused = nil)
175
+ if checkpoint_store.empty?
176
+ puts "No checkpoints available."
177
+ return ""
178
+ end
179
+
180
+ puts "\n=== Available Checkpoints ==="
181
+ checkpoint_store.each do |name, data|
182
+ created = data[:created_at]&.strftime('%H:%M:%S') || 'unknown'
183
+ puts " #{name}: position #{data[:position]}, created #{created}"
184
+ if data[:topic_preview] && !data[:topic_preview].empty?
185
+ puts " → \"#{data[:topic_preview]}\""
186
+ end
187
+ end
188
+ puts "=== End of Checkpoints ==="
189
+ ""
190
+ end
191
+
192
+ # Helper methods
193
+ def self.checkpoint_names
194
+ checkpoint_store.keys
195
+ end
196
+
197
+ def self.checkpoint_positions
198
+ positions = {}
199
+ checkpoint_store.each do |name, data|
200
+ pos = data[:position]
201
+ positions[pos] ||= []
202
+ positions[pos] << name
203
+ end
204
+ positions
205
+ end
206
+
207
+ # Remove checkpoints with position > the given position
208
+ # Returns the count of removed checkpoints
209
+ def self.remove_invalid_checkpoints(max_position)
210
+ invalid_names = checkpoint_store.select { |_name, data| data[:position] > max_position }.keys
211
+ invalid_names.each { |name| checkpoint_store.delete(name) }
212
+ invalid_names.size
213
+ end
214
+
215
+ # Find the previous checkpoint (second-to-last by position)
216
+ # This is used when //restore is called without a name
217
+ def self.find_previous_checkpoint
218
+ return nil if checkpoint_store.size < 2
219
+
220
+ # Sort checkpoints by position descending, return the second one
221
+ sorted = checkpoint_store.sort_by { |_name, data| -data[:position] }
222
+ sorted[1]&.first # Return the name of the second-to-last checkpoint
223
+ end
224
+
225
+ private
226
+
227
+ def self.get_chats
228
+ return nil unless AIA.client.respond_to?(:chats)
229
+
230
+ AIA.client.chats
231
+ end
232
+
233
+ def self.deep_copy_message(msg)
234
+ RubyLLM::Message.new(
235
+ role: msg.role,
236
+ content: msg.content,
237
+ tool_calls: msg.tool_calls&.transform_values { |tc| tc.dup rescue tc },
238
+ tool_call_id: msg.tool_call_id,
239
+ input_tokens: msg.input_tokens,
240
+ output_tokens: msg.output_tokens,
241
+ model_id: msg.model_id
242
+ )
243
+ end
244
+
245
+ def self.format_message_content(msg)
246
+ if msg.tool_call?
247
+ tool_names = msg.tool_calls.values.map(&:name).join(', ')
248
+ "[Tool calls: #{tool_names}]"
249
+ elsif msg.tool_result?
250
+ result_preview = msg.content.to_s[0..50]
251
+ "[Tool result for: #{msg.tool_call_id}] #{result_preview}..."
252
+ else
253
+ content = msg.content.to_s
254
+ content.length > 150 ? "#{content[0..147]}..." : content
255
+ end
256
+ end
257
+
258
+ # Extract a preview of the last user message for checkpoint context
259
+ def self.extract_last_user_message(messages, max_length: 70)
260
+ return "" if messages.nil? || messages.empty?
261
+
262
+ # Find the last user message (not system, not assistant, not tool)
263
+ last_user_msg = messages.reverse.find { |msg| msg.role == :user }
264
+ return "" unless last_user_msg
265
+
266
+ content = last_user_msg.content.to_s.strip
267
+ # Collapse whitespace and truncate
268
+ content = content.gsub(/\s+/, ' ')
269
+ content.length > max_length ? "#{content[0..max_length - 4]}..." : content
270
+ end
271
+
272
+ # Aliases
273
+ class << self
274
+ alias_method :ckp, :checkpoint
275
+ alias_method :cp, :checkpoint
276
+ alias_method :context, :review
277
+ # Route //checkpoints directive to checkpoints_list
278
+ # (safe now that we renamed attr_accessor to checkpoint_store)
279
+ alias_method :checkpoints, :checkpoints_list
280
+ end
281
+ end
282
+ end
283
+ end
@@ -88,100 +88,15 @@ module AIA
88
88
  send(:config, args.prepend('top_p'), context_manager)
89
89
  end
90
90
 
91
- def self.clear(args, context_manager = nil)
92
- if context_manager.nil?
93
- return "Error: Context manager not available for //clear directive."
94
- end
95
-
96
- context_manager.clear_context
97
- ''
98
- end
99
-
100
- def self.review(args, context_manager = nil)
101
- return "Error: Context manager not available for //review directive." if context_manager.nil?
102
-
103
- context = context_manager.get_context
104
- checkpoint_positions = context_manager.checkpoint_positions
105
-
106
- # Display context with checkpoint markers
107
- puts "\n=== Chat Context ==="
108
- puts "Total messages: #{context.size}"
109
-
110
- if checkpoint_positions.any?
111
- puts "Checkpoints: #{context_manager.checkpoint_names.join(', ')}"
112
- end
113
-
114
- puts "\n"
115
-
116
- context.each_with_index do |message, index|
117
- # Check if there's a checkpoint at this position
118
- if checkpoint_positions[index]
119
- checkpoint_names = checkpoint_positions[index].join(', ')
120
- puts "📍 [Checkpoint: #{checkpoint_names}]"
121
- puts "-" * 40
122
- end
123
-
124
- # Display the message
125
- role_display = message[:role].capitalize
126
- content_preview = message[:content].to_s
127
-
128
- # Truncate long content for display
129
- if content_preview.length > 200
130
- content_preview = content_preview[0..197] + "..."
131
- end
132
-
133
- puts "#{index + 1}. [#{role_display}]: #{content_preview}"
134
- puts ""
135
- end
136
-
137
- # Check if there's a checkpoint at the end (after all messages)
138
- if checkpoint_positions[context.size]
139
- checkpoint_names = checkpoint_positions[context.size].join(', ')
140
- puts "📍 [Checkpoint: #{checkpoint_names}]"
141
- puts "-" * 40
142
- end
143
-
144
- puts "=== End of Context ==="
145
- ''
146
- end
147
-
148
- def self.checkpoint(args, context_manager = nil)
149
- if context_manager.nil?
150
- return "Error: Context manager not available for //checkpoint directive."
151
- end
152
-
153
- name = args.empty? ? nil : args.join(' ').strip
154
- checkpoint_name = context_manager.create_checkpoint(name: name)
155
- puts "Checkpoint '#{checkpoint_name}' created."
156
- ""
157
- end
158
-
159
- def self.restore(args, context_manager = nil)
160
- if context_manager.nil?
161
- return "Error: Context manager not available for //restore directive."
162
- end
163
-
164
- name = args.empty? ? nil : args.join(' ').strip
165
-
166
- if context_manager.restore_checkpoint(name: name)
167
- restored_name = name || context_manager.checkpoint_names.last
168
- "Context restored to checkpoint '#{restored_name}'."
169
- else
170
- if name
171
- "Error: Checkpoint '#{name}' not found. Available checkpoints: #{context_manager.checkpoint_names.join(', ')}"
172
- else
173
- "Error: No checkpoints available to restore."
174
- end
175
- end
176
- end
91
+ # NOTE: clear, review, checkpoint, and restore directives have been moved to
92
+ # lib/aia/directives/checkpoint.rb which uses RubyLLM's Chat.@messages
93
+ # as the source of truth for conversation history.
177
94
 
178
95
  # Set up aliases - these work on the module's singleton class
179
96
  class << self
180
97
  alias_method :cfg, :config
181
98
  alias_method :temp, :temperature
182
99
  alias_method :topp, :top_p
183
- alias_method :context, :review
184
- alias_method :ckp, :checkpoint
185
100
  end
186
101
  end
187
102
  end
@@ -231,9 +231,10 @@ module AIA
231
231
  'review' => 'Display the current conversation context with checkpoint markers',
232
232
  'checkpoint' => 'Create a named checkpoint of the current context',
233
233
  'restore' => 'Restore context to a previous checkpoint',
234
+ 'checkpoints_list' => 'List all available checkpoints',
234
235
 
235
236
  # Utility directives
236
- 'tools' => 'List available tools',
237
+ 'tools' => 'List available tools (optional filter by name substring)',
237
238
  'next' => 'Set the next prompt in the sequence',
238
239
  'pipeline' => 'Set or view the prompt workflow sequence',
239
240
  'terse' => 'Add instruction for concise responses',
@@ -261,6 +262,7 @@ module AIA
261
262
  'temp' => nil, # alias for temperature
262
263
  'topp' => nil, # alias for top_p
263
264
  'context' => nil, # alias for review
265
+ 'ckp' => nil, # alias for checkpoint
264
266
  'cp' => nil, # alias for checkpoint
265
267
  'workflow' => nil, # alias for pipeline
266
268
  'rb' => nil, # alias for ruby
@@ -282,13 +284,18 @@ module AIA
282
284
  AIA::Directives::Utility,
283
285
  AIA::Directives::Configuration,
284
286
  AIA::Directives::Execution,
285
- AIA::Directives::Models
287
+ AIA::Directives::Models,
288
+ AIA::Directives::Checkpoint
286
289
  ]
287
290
 
288
291
  all_directives = {}
289
292
  excluded_methods = ['run', 'initialize', 'private?', 'descriptions', 'aliases', 'build_aliases',
290
293
  'desc', 'method_added', 'register_directive_module', 'process',
291
- 'directive?', 'prefix_size']
294
+ 'directive?', 'prefix_size', 'reset!', 'checkpoint_names',
295
+ 'checkpoint_positions', 'get_chats', 'deep_copy_message',
296
+ 'format_message_content', 'checkpoints', 'checkpoint_counter',
297
+ 'last_checkpoint_name', 'checkpoints=', 'checkpoint_counter=',
298
+ 'last_checkpoint_name=']
292
299
 
293
300
  # Collect directives from all modules
294
301
  all_modules.each do |mod|
@@ -314,7 +321,7 @@ module AIA
314
321
  'temperature' => ['temp'],
315
322
  'top_p' => ['topp'],
316
323
  'review' => ['context'],
317
- 'checkpoint' => ['cp'],
324
+ 'checkpoint' => ['ckp', 'cp'],
318
325
  'pipeline' => ['workflow'],
319
326
  'ruby' => ['rb'],
320
327
  'shell' => ['sh'],
@@ -333,7 +340,7 @@ module AIA
333
340
 
334
341
  # Sort and display directives by category
335
342
  categories = {
336
- 'Configuration' => ['config', 'model', 'temperature', 'top_p', 'clear', 'review', 'checkpoint', 'restore'],
343
+ 'Configuration' => ['config', 'model', 'temperature', 'top_p', 'clear', 'review', 'checkpoint', 'restore', 'checkpoints_list'],
337
344
  'Utility' => ['tools', 'next', 'pipeline', 'terse', 'robot', 'help'],
338
345
  'Execution' => ['ruby', 'shell', 'say'],
339
346
  'Web & Files' => ['webpage', 'include'],
@@ -5,6 +5,7 @@ require_relative 'utility'
5
5
  require_relative 'configuration'
6
6
  require_relative 'execution'
7
7
  require_relative 'models'
8
+ require_relative 'checkpoint'
8
9
 
9
10
  module AIA
10
11
  module Directives
@@ -115,6 +116,7 @@ module AIA
115
116
  register_directive_module(Configuration)
116
117
  register_directive_module(Execution)
117
118
  register_directive_module(Models)
119
+ register_directive_module(Checkpoint)
118
120
  end
119
121
  end
120
122
  end
@@ -12,19 +12,34 @@ module AIA
12
12
  indent = 4
13
13
  spaces = " " * indent
14
14
  width = TTY::Screen.width - indent - 2
15
+ filter = args.first&.downcase
15
16
 
16
17
  if AIA.config.tools.empty?
17
18
  puts "No tools are available"
18
19
  else
19
- puts
20
- puts "Available Tools"
21
- puts "==============="
20
+ tools_to_display = AIA.config.tools
22
21
 
23
- AIA.config.tools.each do |tool|
24
- name = tool.respond_to?(:name) ? tool.name : tool.class.name
25
- puts "\n#{name}"
26
- puts "-" * name.size
27
- puts WordWrapper::MinimumRaggedness.new(width, tool.description).wrap.split("\n").map { |s| spaces + s + "\n" }.join
22
+ if filter
23
+ tools_to_display = tools_to_display.select do |tool|
24
+ name = tool.respond_to?(:name) ? tool.name : tool.class.name
25
+ name.downcase.include?(filter)
26
+ end
27
+ end
28
+
29
+ if tools_to_display.empty?
30
+ puts "No tools match the filter: #{args.first}"
31
+ else
32
+ puts
33
+ header = filter ? "Available Tools (filtered by '#{args.first}')" : "Available Tools"
34
+ puts header
35
+ puts "=" * header.length
36
+
37
+ tools_to_display.each do |tool|
38
+ name = tool.respond_to?(:name) ? tool.name : tool.class.name
39
+ puts "\n#{name}"
40
+ puts "-" * name.size
41
+ puts WordWrapper::MinimumRaggedness.new(width, tool.description).wrap.split("\n").map { |s| spaces + s + "\n" }.join
42
+ end
28
43
  end
29
44
  end
30
45
  puts
@@ -5,7 +5,7 @@ require_relative '../extensions/ruby_llm/provider_fix'
5
5
 
6
6
  module AIA
7
7
  class RubyLLMAdapter
8
- attr_reader :tools, :model_specs
8
+ attr_reader :tools, :model_specs, :chats
9
9
 
10
10
  def initialize
11
11
  @model_specs = extract_models_config # Full specs with role info
@@ -228,7 +228,17 @@ module AIA
228
228
 
229
229
  def support_local_tools
230
230
  @tools += ObjectSpace.each_object(Class).select do |klass|
231
- klass < RubyLLM::Tool
231
+ next false unless klass < RubyLLM::Tool
232
+
233
+ # Filter out tools that can't be instantiated without arguments
234
+ # RubyLLM calls tool.new without args, so we must verify each tool works
235
+ begin
236
+ klass.new
237
+ true
238
+ rescue ArgumentError, LoadError, StandardError
239
+ # Skip tools that require arguments or have missing dependencies
240
+ false
241
+ end
232
242
  end
233
243
  end
234
244
 
@@ -634,7 +644,10 @@ module AIA
634
644
 
635
645
  @tools.select! do |tool|
636
646
  tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
637
- AIA.config.allowed_tools.any? { |allowed| tool_name.include?(allowed) }
647
+ AIA.config.allowed_tools
648
+ .split(',')
649
+ .map(&:strip)
650
+ .any? { |allowed| tool_name.include?(allowed) }
638
651
  end
639
652
  end
640
653
 
@@ -644,7 +657,10 @@ module AIA
644
657
 
645
658
  @tools.reject! do |tool|
646
659
  tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
647
- AIA.config.rejected_tools.any? { |rejected| tool_name.include?(rejected) }
660
+ AIA.config.rejected_tools
661
+ .split(',')
662
+ .map(&:strip)
663
+ .any? { |rejected| tool_name.include?(rejected) }
648
664
  end
649
665
  end
650
666