ruboty-ai_agent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +19 -0
  3. data/Gemfile +10 -3
  4. data/Rakefile +2 -0
  5. data/Steepfile +1 -0
  6. data/lib/ruboty/ai_agent/actions/add_ai_command.rb +16 -1
  7. data/lib/ruboty/ai_agent/actions/chat.rb +44 -16
  8. data/lib/ruboty/ai_agent/actions/list_ai_commands.rb +20 -4
  9. data/lib/ruboty/ai_agent/actions/remove_ai_command.rb +7 -1
  10. data/lib/ruboty/ai_agent/actions/show_system_prompt.rb +1 -1
  11. data/lib/ruboty/ai_agent/chat_message.rb +7 -2
  12. data/lib/ruboty/ai_agent/chat_thread_messages.rb +40 -0
  13. data/lib/ruboty/ai_agent/commands/base.rb +11 -14
  14. data/lib/ruboty/ai_agent/commands/builtin_base.rb +39 -0
  15. data/lib/ruboty/ai_agent/commands/clear.rb +2 -14
  16. data/lib/ruboty/ai_agent/commands/compact.rb +4 -47
  17. data/lib/ruboty/ai_agent/commands/prompt_command.rb +61 -0
  18. data/lib/ruboty/ai_agent/commands/usage.rb +2 -14
  19. data/lib/ruboty/ai_agent/commands.rb +3 -1
  20. data/lib/ruboty/ai_agent/database/query_methods.rb +31 -6
  21. data/lib/ruboty/ai_agent/database.rb +2 -1
  22. data/lib/ruboty/ai_agent/http_mcp_client.rb +5 -2
  23. data/lib/ruboty/ai_agent/llm/openai.rb +5 -5
  24. data/lib/ruboty/ai_agent/mcp_clients.rb +6 -0
  25. data/lib/ruboty/ai_agent/mcp_configuration.rb +3 -2
  26. data/lib/ruboty/ai_agent/prompt_command_definition.rb +17 -0
  27. data/lib/ruboty/ai_agent/record_set.rb +9 -5
  28. data/lib/ruboty/ai_agent/recordable.rb +11 -9
  29. data/lib/ruboty/ai_agent/token_usage.rb +10 -0
  30. data/lib/ruboty/ai_agent/user.rb +5 -0
  31. data/lib/ruboty/ai_agent/user_mcp_caches.rb +2 -2
  32. data/lib/ruboty/ai_agent/user_mcp_client.rb +2 -2
  33. data/lib/ruboty/ai_agent/user_prompt_command_definitions.rb +17 -0
  34. data/lib/ruboty/ai_agent/version.rb +1 -1
  35. data/lib/ruboty/ai_agent.rb +11 -0
  36. data/lib/ruboty/handlers/ai_agent.rb +27 -15
  37. data/rbs_collection.yaml +1 -0
  38. data/ruboty-ai_agent.gemspec +2 -6
  39. data/script/clean-orphaned-rbs.rb +105 -0
  40. data/script/generate-concern-rbs.rb +5 -5
  41. data/script/generate-data-rbs.rb +3 -5
  42. data/script/generate-memorized-ivar-rbs.rb +6 -11
  43. data/sig/generated/ruboty/ai_agent/actions/add_ai_command.rbs +4 -0
  44. data/sig/generated/ruboty/ai_agent/actions/chat.rbs +6 -0
  45. data/sig/generated/ruboty/ai_agent/chat_message.rbs +5 -2
  46. data/sig/generated/ruboty/ai_agent/chat_thread_messages.rbs +14 -0
  47. data/sig/generated/ruboty/ai_agent/commands/base.rbs +8 -20
  48. data/sig/generated/ruboty/ai_agent/commands/builtin_base.rbs +40 -0
  49. data/sig/generated/ruboty/ai_agent/commands/clear.rbs +2 -10
  50. data/sig/generated/ruboty/ai_agent/commands/compact.rbs +2 -20
  51. data/sig/generated/ruboty/ai_agent/commands/prompt_command.rbs +27 -0
  52. data/sig/generated/ruboty/ai_agent/commands/usage.rbs +2 -10
  53. data/sig/generated/ruboty/ai_agent/commands.rbs +2 -2
  54. data/sig/generated/ruboty/ai_agent/database/query_methods.rbs +18 -12
  55. data/sig/generated/ruboty/ai_agent/database.rbs +3 -1
  56. data/sig/generated/ruboty/ai_agent/llm/openai.rbs +3 -3
  57. data/sig/generated/ruboty/ai_agent/mcp_configuration.rbs +4 -2
  58. data/sig/generated/ruboty/ai_agent/prompt_command_definition.rbs +23 -0
  59. data/sig/generated/ruboty/ai_agent/record_set.rbs +11 -9
  60. data/sig/generated/ruboty/ai_agent/recordable.rbs +6 -6
  61. data/sig/generated/ruboty/ai_agent/token_usage.rbs +4 -0
  62. data/sig/generated/ruboty/ai_agent/user.rbs +4 -0
  63. data/sig/generated/ruboty/ai_agent/user_prompt_command_definitions.rbs +12 -0
  64. data/sig/generated/ruboty/handlers/ai_agent.rbs +12 -12
  65. data/sig/generated-by-scripts/concerns.rbs +5 -0
  66. data/sig/generated-by-scripts/memorized_ivars.rbs +9 -0
  67. metadata +11 -57
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1782e855675403948a771dd5abe6fdba6a754cfc6e15bebad40f24bf3c7569f6
4
- data.tar.gz: 2142af0fb45049c4e1b2397def8009248db8860e43a9fa2ae21f938a31e3af82
3
+ metadata.gz: 6ee903a1f588ede1c0b6e68bea298457b3ee2fbe826494f036f9c6482288475a
4
+ data.tar.gz: 9acf203be78f86b2eeaf1f6810606a4a5b0f0a05709aba6c52b7194a589ed533
5
5
  SHA512:
6
- metadata.gz: 1a1b7fc8e8e41150055e87d2227e9b0ae8570c69482cf72249af38abef0978058419eed73f778a0f4d9c00343a55ce96be0cd62febdb4a20cd8b7976a6852415
7
- data.tar.gz: 73aff06e4a6d7fb3601b9c95393b9c1496fd61eddaaabf0fe742e7701faad638a36c3919462e65847dab47d0450579c8047ba492beb4d51c81181b5b66307aef
6
+ metadata.gz: a59e8c688b63d055d59389a95b95a6a5637bac86d917e35a4bd90b7260bc2028b2defd00c6e7bd3a5b26d5cfdb22536de5e4a1428b4e597c29ae573898021922
7
+ data.tar.gz: 1daed254a4b194c16b3b9dda2180c59e21cb5597d7c965e20905c302523b1f3a9f2a2829528e7746370fa567de487389c5846b392167c7331d8e00422a45a73e
data/.rubocop.yml CHANGED
@@ -11,9 +11,11 @@
11
11
  plugins:
12
12
  - rubocop-rake
13
13
  - rubocop-rbs_inline
14
+ - rubocop-rspec
14
15
 
15
16
  AllCops:
16
17
  TargetRubyVersion: 3.1
18
+ NewCops: enable
17
19
 
18
20
  Naming/AccessorMethodName:
19
21
  Exclude:
@@ -33,6 +35,23 @@ Layout/LeadingCommentSpace:
33
35
  Metrics:
34
36
  Enabled: false
35
37
 
38
+ RSpec/ExampleLength:
39
+ Max: 7
40
+ CountAsOne: ["array", "heredoc", "method_call"]
41
+
42
+ RSpec/MultipleMemoizedHelpers:
43
+ Enabled: false
44
+
45
+ RSpec/MultipleExpectations:
46
+ Enabled: false
47
+
48
+ RSpec/NestedGroups:
49
+ Enabled: false
50
+
51
+ RSpec/SpecFilePathFormat:
52
+ CustomTransform:
53
+ OpenAI: openai
54
+
36
55
  Style/CommentedKeyword:
37
56
  Enabled: false # because of RBS annotations
38
57
 
data/Gemfile CHANGED
@@ -7,8 +7,15 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
7
7
  # Specify your gem's dependencies in ruboty-ai_agent.gemspec
8
8
  gemspec
9
9
 
10
+ gem 'bump', '~> 0.10.0'
11
+ gem 'bundler', '~> 2'
12
+ gem 'rake', '~> 13'
13
+ gem 'rspec', '~> 3.0'
14
+ gem 'webmock', '~> 3.0'
15
+
10
16
  gem 'rbs-inline', require: false
11
- gem 'rubocop'
12
- gem 'rubocop-rake'
13
- gem 'rubocop-rbs_inline'
17
+ gem 'rubocop', '~> 1.74.0'
18
+ gem 'rubocop-rake', '~> 0.7.0'
19
+ gem 'rubocop-rbs_inline', '~> 1.4.0'
20
+ gem 'rubocop-rspec', '~> 3.7.0'
14
21
  gem 'steep', require: false
data/Rakefile CHANGED
@@ -4,6 +4,7 @@ require 'bundler/gem_tasks'
4
4
  require 'rspec/core/rake_task'
5
5
  require 'rubocop/rake_task'
6
6
  require 'steep/rake_task'
7
+ require 'bump/tasks'
7
8
 
8
9
  RSpec::Core::RakeTask.new(:spec)
9
10
 
@@ -33,6 +34,7 @@ namespace :rbs do
33
34
 
34
35
  desc 'Run rbs-inline to generate RBS files'
35
36
  task :inline do
37
+ sh('script/clean-orphaned-rbs.rb')
36
38
  sh('bundle exec rbs-inline --opt-out --output lib')
37
39
  end
38
40
 
data/Steepfile CHANGED
@@ -9,4 +9,5 @@ target :lib do
9
9
  library 'json'
10
10
  library 'net-http'
11
11
  library 'optparse'
12
+ library 'shellwords'
12
13
  end
@@ -6,7 +6,14 @@ module Ruboty
6
6
  # AddAiCommand action for Ruboty::AiAgent
7
7
  class AddAiCommand < Base
8
8
  def call
9
- message.reply("TODO: Implement AddAiCommand action - name: #{name_param}, prompt: #{prompt_param}")
9
+ if user.prompt_command_definitions.key?(name_param)
10
+ message.reply("Command '/#{name_param}' already exists. Use a different name or remove the existing command first.")
11
+ return
12
+ end
13
+
14
+ new_command = PromptCommandDefinition.new(name: name_param, prompt: undump_string(prompt_param))
15
+ user.prompt_command_definitions.add(new_command)
16
+ message.reply("Command '/#{name_param}' has been added successfully!")
10
17
  end
11
18
 
12
19
  def name_param #: String
@@ -16,6 +23,14 @@ module Ruboty
16
23
  def prompt_param #: String
17
24
  message[:prompt]
18
25
  end
26
+
27
+ # @rbs str: String
28
+ # @rbs return: String
29
+ def undump_string(str)
30
+ str.undump
31
+ rescue StandardError
32
+ str
33
+ end
19
34
  end
20
35
  end
21
36
  end
@@ -9,28 +9,58 @@ module Ruboty
9
9
 
10
10
  # @rbs override
11
11
  def call
12
- llm = LLM::OpenAI.new(
13
- client: OpenAI::Client.new(
14
- api_key: ENV.fetch('OPENAI_API_KEY', nil)
15
- ),
16
- model: ENV.fetch('OPENAI_MODEL', 'gpt-5-nano')
17
- )
18
-
19
- commands = Commands.builtins(message:, chat_thread:)
20
- tools = McpClients.new(user.mcp_clients).available_tools
12
+ user.prompt_command_definitions.all_values.each do |definition|
13
+ command = Commands::PromptCommand.new(definition:, message:, chat_thread:)
14
+ if command.match?(body_param)
15
+ new_prompt = command.call(commandline: body_param)
16
+ return complete_chat(new_prompt)
17
+ end
18
+ end
21
19
 
22
- commands.each do |command|
20
+ builtin_commands = Commands.builtins(message:, chat_thread:)
21
+ builtin_commands.each do |command|
23
22
  return command.call if command.match?(body_param)
24
23
  end
25
24
 
25
+ complete_chat(body_param)
26
+ end
27
+
28
+ def body_param #: String
29
+ message[:body]
30
+ end
31
+
32
+ private
33
+
34
+ # @rbs body: String
35
+ # @rbs return: void
36
+ def complete_chat(body)
26
37
  chat_thread.messages << ChatMessage.new(
27
38
  role: :user,
28
- content: body_param
39
+ content: body
29
40
  )
30
41
 
42
+ messages = [] #: Array[ChatMessage]
43
+
44
+ global_prompt = database.global_settings.system_prompt
45
+ messages << ChatMessage.new(role: :system, content: global_prompt) if global_prompt
46
+
47
+ user_prompt = user.system_prompt
48
+ messages << ChatMessage.new(role: :system, content: user_prompt) if user_prompt
49
+
50
+ ai_memories = user.ai_memories.all || {}
51
+ unless ai_memories.empty?
52
+ memory_content = ai_memories.map { |_idx, memory| memory }.join("\n\n")
53
+ messages << ChatMessage.new(role: :user, content: "My memories:\n#{memory_content}")
54
+ end
55
+
56
+ messages += chat_thread.messages.all_values
57
+
58
+ llm = LLM::OpenAI.new
59
+ tools = McpClients.new(user.mcp_clients).available_tools
60
+
31
61
  agent = Agent.new(
32
62
  llm:,
33
- messages: chat_thread.messages.all_values,
63
+ messages:,
34
64
  tools:
35
65
  )
36
66
 
@@ -39,6 +69,8 @@ module Ruboty
39
69
  when :new_message
40
70
  chat_thread.messages << event[:message]
41
71
  message.reply(event[:message].content) if event[:message].content.length.positive?
72
+
73
+ chat_thread.messages.compact(llm:) if chat_thread.messages.over_auto_compact_threshold?
42
74
  when :tool_call
43
75
  message.reply("Calling tool #{event[:tool].name} with arguments #{event[:tool_arguments]}",
44
76
  streaming: true)
@@ -54,10 +86,6 @@ module Ruboty
54
86
  message.reply("エラーが発生しました: #{e.message}")
55
87
  end
56
88
  end
57
-
58
- def body_param #: String
59
- message[:body]
60
- end
61
89
  end
62
90
  end
63
91
  end
@@ -7,11 +7,27 @@ module Ruboty
7
7
  class ListAiCommands < Base
8
8
  # @rbs override
9
9
  def call
10
- commands = Commands.builtins(message:, chat_thread:)
10
+ builtin_commands = Commands.builtins(message:, chat_thread:)
11
11
 
12
- message.reply(
13
- commands.flat_map(&:matchers).map { |matcher| "#{matcher.pattern.inspect} - #{matcher.description}" }.join("\n")
14
- )
12
+ builtin_list = builtin_commands.flat_map(&:matchers)
13
+ .map { |matcher| "#{matcher.pattern.inspect} - #{matcher.description}" }
14
+
15
+ user_defined_list = user.prompt_command_definitions.all_values
16
+ .map { |cmd| "/#{cmd.name} - #{cmd.prompt}" }
17
+
18
+ content = <<~TEXT
19
+ # User-Defined Commands
20
+ #{user_defined_list.empty? ? 'No user-defined commands available.' : user_defined_list.join("\n")}
21
+
22
+ # Built-in Commands
23
+ #{builtin_list.empty? ? 'No built-in commands available.' : builtin_list.join("\n")}
24
+ TEXT
25
+
26
+ if builtin_commands.empty? && user_defined_list.empty?
27
+ message.reply('No commands available.')
28
+ else
29
+ message.reply(content)
30
+ end
15
31
  end
16
32
  end
17
33
  end
@@ -6,7 +6,13 @@ module Ruboty
6
6
  # RemoveAiCommand action for Ruboty::AiAgent
7
7
  class RemoveAiCommand < Base
8
8
  def call
9
- message.reply("TODO: Implement RemoveAiCommand action for name: #{name_param}")
9
+ unless user.prompt_command_definitions.key?(name_param)
10
+ message.reply("Command '/#{name_param}' does not exist.")
11
+ return
12
+ end
13
+
14
+ user.prompt_command_definitions.remove(name_param)
15
+ message.reply("Command '/#{name_param}' has been removed successfully!")
10
16
  end
11
17
 
12
18
  def name_param #: String
@@ -9,7 +9,7 @@ module Ruboty
9
9
  user_prompt = user.system_prompt
10
10
  global_prompt = database.global_settings.system_prompt
11
11
 
12
- reply = []
12
+ reply = [] #: Array[String]
13
13
  reply << if user_prompt
14
14
  "User system prompt: #{user_prompt}"
15
15
  else
@@ -15,14 +15,14 @@ module Ruboty
15
15
  attr_reader :tool_arguments #: Hash[Symbol | String, untyped]?
16
16
  attr_reader :token_usage #: TokenUsage?
17
17
 
18
- # @rbs role: Symbol
18
+ # @rbs role: Symbol | String
19
19
  # @rbs content: String
20
20
  # @rbs ?tool_call_id: String?
21
21
  # @rbs ?tool_name: String?
22
22
  # @rbs ?tool_arguments: Hash[Symbol | String, untyped]?
23
23
  # @rbs ?token_usage: TokenUsage?
24
24
  def initialize(role:, content:, tool_call_id: nil, tool_name: nil, tool_arguments: nil, token_usage: nil)
25
- @role = role
25
+ @role = role.to_sym
26
26
  @content = content
27
27
  @tool_call_id = tool_call_id
28
28
  @tool_name = tool_name
@@ -41,6 +41,11 @@ module Ruboty
41
41
  }
42
42
  end
43
43
 
44
+ # @rbs return: bool
45
+ def tool_call?
46
+ !!(tool_call_id && !tool_call_id.empty?)
47
+ end
48
+
44
49
  def self.from_llm_response(
45
50
  tool:,
46
51
  tool_call_id:,
@@ -12,6 +12,46 @@ module Ruboty
12
12
  end
13
13
 
14
14
  alias << add
15
+
16
+ # Check if any message's token usage exceeds auto compact threshold
17
+ # @rbs return: bool
18
+ def over_auto_compact_threshold?
19
+ all_values.any? { |message| message.token_usage&.over_auto_compact_threshold? }
20
+ end
21
+
22
+ # Compact chat messages by summarizing them
23
+ # @rbs llm: LLM::OpenAI
24
+ # @rbs return: void
25
+ def compact(llm:)
26
+ messages = all_values
27
+ return if messages.empty?
28
+
29
+ last_assistant_message = messages.reverse.find { |msg| msg.role == :assistant }
30
+
31
+ summary = summarize(llm:)
32
+
33
+ clear
34
+ add(ChatMessage.new(
35
+ role: :system,
36
+ content: "Previous conversation summary: #{summary}"
37
+ ))
38
+ add(last_assistant_message) if last_assistant_message&.tool_call?
39
+ end
40
+
41
+ # Generate summary of chat messages
42
+ # @rbs llm: LLM::OpenAI
43
+ # @rbs return: String
44
+ def summarize(llm:)
45
+ summary_prompt = ChatMessage.new(
46
+ role: :system,
47
+ content: <<~TEXT
48
+ Please summarize the following conversation in a concise manner, capturing the key topics, decisions, and context that would be helpful for continuing the conversation:
49
+ TEXT
50
+ )
51
+
52
+ response = llm.complete(messages: [summary_prompt, *all_values])
53
+ response.message.content
54
+ end
15
55
  end
16
56
  end
17
57
  end
@@ -6,32 +6,29 @@ module Ruboty
6
6
  # Base class for commands.
7
7
  # @abstract
8
8
  class Base
9
- Matcher = Data.define(:pattern, :description, :name)
9
+ attr_reader :message #: Ruboty::Message
10
+ attr_reader :chat_thread #: Ruboty::AiAgent::ChatThread
10
11
 
11
- class << self
12
- def matchers #: Array[Matcher]
13
- @matchers ||= []
14
- end
12
+ # @rbs message: Ruboty::Message
13
+ # @rbs chat_thread: Ruboty::AiAgent::ChatThread
14
+ def initialize(message:, chat_thread:)
15
+ @message = message
16
+ @chat_thread = chat_thread
15
17
 
16
- def on(pattern, name:, description:)
17
- matchers << Matcher.new(pattern:, description:, name:)
18
- end
18
+ super()
19
19
  end
20
20
 
21
21
  # @rbs *args: untyped
22
- # @rbs return: void
22
+ # @rbs return: untyped
23
23
  def call(*args)
24
24
  raise NotImplementedError
25
25
  end
26
26
 
27
27
  # @rbs commandline: String
28
28
  # @rbs return: boolish
29
+ # @abstract
29
30
  def match?(commandline)
30
- matchers.any? { |matcher| /\A\s*#{matcher.pattern}/.match?(commandline) }
31
- end
32
-
33
- def matchers #: Array[Matcher]
34
- self.class.matchers
31
+ raise NotImplementedError
35
32
  end
36
33
  end
37
34
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Commands
6
+ # Base class for commands.
7
+ # @abstract
8
+ class BuiltinBase < Base
9
+ Matcher = Data.define(:pattern, :description, :name)
10
+
11
+ class << self
12
+ def matchers #: Array[Matcher]
13
+ @matchers ||= []
14
+ end
15
+
16
+ def on(pattern, name:, description:)
17
+ matchers << Matcher.new(pattern:, description:, name:)
18
+ end
19
+ end
20
+
21
+ # @rbs *args: untyped
22
+ # @rbs return: void
23
+ def call(*args)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ # @rbs commandline: String
28
+ # @rbs return: boolish
29
+ def match?(commandline)
30
+ matchers.any? { |matcher| /\A\s*#{matcher.pattern}/.match?(commandline) }
31
+ end
32
+
33
+ def matchers #: Array[Matcher]
34
+ self.class.matchers
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -4,22 +4,10 @@ module Ruboty
4
4
  module AiAgent
5
5
  module Commands
6
6
  # Clear histories of the chat thread.
7
- class Clear < Base
7
+ class Clear < BuiltinBase
8
8
  on(%r{/clear}, name: 'clear', description: 'Clear the chat history.')
9
9
 
10
- attr_reader :message #: Ruboty::Message
11
- attr_reader :chat_thread #: Ruboty::AiAgent::ChatThread
12
-
13
- # @rbs message: Ruboty::Message
14
- # @rbs chat_thread: Ruboty::AiAgent::ChatThread
15
- def initialize(message:, chat_thread:)
16
- @message = message
17
- @chat_thread = chat_thread
18
-
19
- super()
20
- end
21
-
22
- def call #: void
10
+ def call(*) #: void
23
11
  chat_thread.clear
24
12
  message.reply('Cleared the chat history.')
25
13
  end
@@ -4,30 +4,16 @@ module Ruboty
4
4
  module AiAgent
5
5
  module Commands
6
6
  # Compact chat history by summarizing it
7
- class Compact < Base
7
+ class Compact < BuiltinBase
8
8
  on(%r{/compact}, name: 'compact', description: 'Compact the chat history by summarizing it.')
9
9
 
10
- attr_reader :message #: Ruboty::Message
11
- attr_reader :chat_thread #: Ruboty::AiAgent::ChatThread
12
-
13
- # @rbs message: Ruboty::Message
14
- # @rbs chat_thread: Ruboty::AiAgent::ChatThread
15
- def initialize(message:, chat_thread:)
16
- @message = message
17
- @chat_thread = chat_thread
18
-
19
- super()
20
- end
21
-
22
- def call #: void
23
- messages = chat_thread.messages.all_values
24
-
25
- if messages.empty?
10
+ def call(*) #: void
11
+ if chat_thread.messages.empty?
26
12
  message.reply('No chat history to compact.')
27
13
  return
28
14
  end
29
15
 
30
- summary = generate_summary(messages)
16
+ summary = chat_thread.messages.summarize(llm: LLM::OpenAI.new)
31
17
 
32
18
  chat_thread.clear
33
19
  chat_thread.messages.add(
@@ -45,35 +31,6 @@ module Ruboty
45
31
  message.reply("エラーが発生しました: #{e.message}")
46
32
  end
47
33
  end
48
-
49
- private
50
-
51
- # @rbs messages: Array[ChatMessage]
52
- # @rbs return: String
53
- def generate_summary(messages)
54
- llm = LLM::OpenAI.new(
55
- client: OpenAI::Client.new(
56
- api_key: ENV.fetch('OPENAI_API_KEY', nil)
57
- ),
58
- model: ENV.fetch('OPENAI_MODEL', 'gpt-5-nano')
59
- )
60
-
61
- summary_prompt = ChatMessage.new(
62
- role: :user,
63
- content: "Please summarize the following conversation in a concise manner, capturing the key topics, decisions, and context that would be helpful for continuing the conversation:\n\n#{format_messages_for_summary(messages)}"
64
- )
65
-
66
- response = llm.complete(messages: [summary_prompt])
67
- response.message.content
68
- end
69
-
70
- # @rbs messages: Array[ChatMessage]
71
- # @rbs return: String
72
- def format_messages_for_summary(messages)
73
- messages.map do |msg|
74
- "#{msg.role}: #{msg.content}"
75
- end.join("\n")
76
- end
77
34
  end
78
35
  end
79
36
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Ruboty
6
+ module AiAgent
7
+ module Commands
8
+ # Compact chat history by summarizing it
9
+ class PromptCommand < Base
10
+ attr_reader :definition #: Ruboty::AiAgent::PromptCommandDefinition
11
+
12
+ # @rbs definition: Ruboty::AiAgent::PromptCommandDefinition
13
+ # @rbs message: Ruboty::Message
14
+ # @rbs chat_thread: Ruboty::AiAgent::ChatThread
15
+ def initialize(definition:, message:, chat_thread:)
16
+ @definition = definition
17
+
18
+ super(message:, chat_thread:)
19
+ end
20
+
21
+ # @rbs commandline: String
22
+ # @rbs return: boolish
23
+ def match?(commandline)
24
+ pattern.match?(commandline)
25
+ end
26
+
27
+ def pattern #: Regexp
28
+ %r{\A\s*/#{definition.name}(?:\s+(?<args>.+))?}
29
+ end
30
+
31
+ # @rbs commandline: String
32
+ # @rbs return: String
33
+ def call(commandline:)
34
+ result = definition.prompt.dup
35
+
36
+ match = pattern.match(commandline) || (raise 'Unreachable')
37
+ args = begin
38
+ Shellwords.split(match[:args] || '')
39
+ rescue ArgumentError
40
+ # If parsing fails, treat the entire string as a single argument
41
+ [match[:args]].compact
42
+ end #: Array[String]
43
+
44
+ found = {} #: Hash[Integer, boolish]
45
+ args.each_with_index do |arg, index|
46
+ placeholder = "$#{index + 1}"
47
+ result = result.gsub(placeholder) do
48
+ found[index] = true
49
+ arg
50
+ end
51
+ end
52
+
53
+ rest_args = args.each_with_index.reject { |_, index| found[index] }.map(&:first)
54
+ result += "\n\n#{rest_args.join(' ')}" if rest_args.any?
55
+
56
+ result
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -4,22 +4,10 @@ module Ruboty
4
4
  module AiAgent
5
5
  module Commands
6
6
  # Show token usage information for the latest AI response
7
- class Usage < Base
7
+ class Usage < BuiltinBase
8
8
  on(%r{/usage}, name: 'show_usage', description: 'Show token usage information for the latest AI response')
9
9
 
10
- attr_reader :message #: Ruboty::Message
11
- attr_reader :chat_thread #: Ruboty::AiAgent::ChatThread
12
-
13
- # @rbs message: Ruboty::Message
14
- # @rbs chat_thread: Ruboty::AiAgent::ChatThread
15
- def initialize(message:, chat_thread:)
16
- @message = message
17
- @chat_thread = chat_thread
18
-
19
- super()
20
- end
21
-
22
- def call #: void
10
+ def call(*) #: void
23
11
  latest_message = chat_thread.messages.all_values.find(&:token_usage)
24
12
 
25
13
  token_usage = latest_message&.token_usage
@@ -5,13 +5,15 @@ module Ruboty
5
5
  # Interaction commands (a.k.a. Slash commands, Prompts, etc)
6
6
  module Commands
7
7
  autoload :Base, 'ruboty/ai_agent/commands/base'
8
+ autoload :BuiltinBase, 'ruboty/ai_agent/commands/builtin_base'
8
9
  autoload :Clear, 'ruboty/ai_agent/commands/clear'
9
10
  autoload :Compact, 'ruboty/ai_agent/commands/compact'
10
11
  autoload :Usage, 'ruboty/ai_agent/commands/usage'
12
+ autoload :PromptCommand, 'ruboty/ai_agent/commands/prompt_command'
11
13
 
12
14
  # @rbs message: Ruboty::Message
13
15
  # @rbs chat_thread: ChatThread
14
- # @rbs return: Array[Commands::Base]
16
+ # @rbs return: Array[Commands::BuiltinBase]
15
17
  def self.builtins(message:, chat_thread:)
16
18
  [
17
19
  Commands::Clear.new(