ruboty-ai_agent 0.1.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 (124) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +45 -0
  5. data/AGENTS.md +22 -0
  6. data/CHANGELOG.md +3 -0
  7. data/CLAUDE.md +1 -0
  8. data/CODE_OF_CONDUCT.md +74 -0
  9. data/Gemfile +14 -0
  10. data/README.md +118 -0
  11. data/Rakefile +47 -0
  12. data/Steepfile +12 -0
  13. data/bin/console +15 -0
  14. data/bin/ruboty +34 -0
  15. data/bin/setup +8 -0
  16. data/lib/ruboty/ai_agent/actions/add_ai_command.rb +22 -0
  17. data/lib/ruboty/ai_agent/actions/add_ai_memory.rb +20 -0
  18. data/lib/ruboty/ai_agent/actions/add_mcp.rb +94 -0
  19. data/lib/ruboty/ai_agent/actions/base.rb +43 -0
  20. data/lib/ruboty/ai_agent/actions/chat.rb +64 -0
  21. data/lib/ruboty/ai_agent/actions/list_ai_commands.rb +19 -0
  22. data/lib/ruboty/ai_agent/actions/list_ai_memories.rb +18 -0
  23. data/lib/ruboty/ai_agent/actions/list_mcp.rb +18 -0
  24. data/lib/ruboty/ai_agent/actions/remove_ai_command.rb +18 -0
  25. data/lib/ruboty/ai_agent/actions/remove_ai_memory.rb +25 -0
  26. data/lib/ruboty/ai_agent/actions/remove_mcp.rb +24 -0
  27. data/lib/ruboty/ai_agent/actions/set_system_prompt.rb +31 -0
  28. data/lib/ruboty/ai_agent/actions/show_system_prompt.rb +30 -0
  29. data/lib/ruboty/ai_agent/actions.rb +22 -0
  30. data/lib/ruboty/ai_agent/agent.rb +71 -0
  31. data/lib/ruboty/ai_agent/cached_value.rb +43 -0
  32. data/lib/ruboty/ai_agent/chat_message.rb +60 -0
  33. data/lib/ruboty/ai_agent/chat_thread.rb +31 -0
  34. data/lib/ruboty/ai_agent/chat_thread_associations.rb +34 -0
  35. data/lib/ruboty/ai_agent/chat_thread_messages.rb +17 -0
  36. data/lib/ruboty/ai_agent/commands/base.rb +39 -0
  37. data/lib/ruboty/ai_agent/commands/clear.rb +29 -0
  38. data/lib/ruboty/ai_agent/commands/compact.rb +80 -0
  39. data/lib/ruboty/ai_agent/commands/usage.rb +52 -0
  40. data/lib/ruboty/ai_agent/commands.rb +33 -0
  41. data/lib/ruboty/ai_agent/database/query_methods.rb +84 -0
  42. data/lib/ruboty/ai_agent/database.rb +40 -0
  43. data/lib/ruboty/ai_agent/global_settings.rb +33 -0
  44. data/lib/ruboty/ai_agent/http_mcp_client.rb +215 -0
  45. data/lib/ruboty/ai_agent/llm/openai/model.rb +29 -0
  46. data/lib/ruboty/ai_agent/llm/openai.rb +181 -0
  47. data/lib/ruboty/ai_agent/llm/response.rb +21 -0
  48. data/lib/ruboty/ai_agent/llm.rb +11 -0
  49. data/lib/ruboty/ai_agent/mcp_clients.rb +48 -0
  50. data/lib/ruboty/ai_agent/mcp_configuration.rb +31 -0
  51. data/lib/ruboty/ai_agent/record_set.rb +71 -0
  52. data/lib/ruboty/ai_agent/recordable.rb +116 -0
  53. data/lib/ruboty/ai_agent/token_usage.rb +45 -0
  54. data/lib/ruboty/ai_agent/tool.rb +29 -0
  55. data/lib/ruboty/ai_agent/user.rb +52 -0
  56. data/lib/ruboty/ai_agent/user_ai_memories.rb +17 -0
  57. data/lib/ruboty/ai_agent/user_associations.rb +34 -0
  58. data/lib/ruboty/ai_agent/user_mcp_caches.rb +90 -0
  59. data/lib/ruboty/ai_agent/user_mcp_client.rb +93 -0
  60. data/lib/ruboty/ai_agent/user_mcp_configurations.rb +15 -0
  61. data/lib/ruboty/ai_agent/user_mcp_tools_caches.rb +14 -0
  62. data/lib/ruboty/ai_agent/version.rb +7 -0
  63. data/lib/ruboty/ai_agent.rb +40 -0
  64. data/lib/ruboty/handlers/ai_agent.rb +84 -0
  65. data/rbs_collection.yaml +23 -0
  66. data/ruboty-ai_agent.gemspec +49 -0
  67. data/script/generate-concern-rbs.rb +351 -0
  68. data/script/generate-data-rbs.rb +250 -0
  69. data/script/generate-memorized-ivar-rbs.rb +292 -0
  70. data/sig/generated/ruboty/ai_agent/actions/add_ai_command.rbs +16 -0
  71. data/sig/generated/ruboty/ai_agent/actions/add_ai_memory.rbs +14 -0
  72. data/sig/generated/ruboty/ai_agent/actions/add_mcp.rbs +26 -0
  73. data/sig/generated/ruboty/ai_agent/actions/base.rbs +34 -0
  74. data/sig/generated/ruboty/ai_agent/actions/chat.rbs +17 -0
  75. data/sig/generated/ruboty/ai_agent/actions/list_ai_commands.rbs +13 -0
  76. data/sig/generated/ruboty/ai_agent/actions/list_ai_memories.rbs +12 -0
  77. data/sig/generated/ruboty/ai_agent/actions/list_mcp.rbs +12 -0
  78. data/sig/generated/ruboty/ai_agent/actions/remove_ai_command.rbs +14 -0
  79. data/sig/generated/ruboty/ai_agent/actions/remove_ai_memory.rbs +14 -0
  80. data/sig/generated/ruboty/ai_agent/actions/remove_mcp.rbs +14 -0
  81. data/sig/generated/ruboty/ai_agent/actions/set_system_prompt.rbs +16 -0
  82. data/sig/generated/ruboty/ai_agent/actions/show_system_prompt.rbs +12 -0
  83. data/sig/generated/ruboty/ai_agent/actions.rbs +9 -0
  84. data/sig/generated/ruboty/ai_agent/agent.rbs +29 -0
  85. data/sig/generated/ruboty/ai_agent/cached_value.rbs +28 -0
  86. data/sig/generated/ruboty/ai_agent/chat_message.rbs +34 -0
  87. data/sig/generated/ruboty/ai_agent/chat_thread.rbs +22 -0
  88. data/sig/generated/ruboty/ai_agent/chat_thread_associations.rbs +21 -0
  89. data/sig/generated/ruboty/ai_agent/chat_thread_messages.rbs +13 -0
  90. data/sig/generated/ruboty/ai_agent/commands/base.rbs +40 -0
  91. data/sig/generated/ruboty/ai_agent/commands/clear.rbs +20 -0
  92. data/sig/generated/ruboty/ai_agent/commands/compact.rbs +30 -0
  93. data/sig/generated/ruboty/ai_agent/commands/usage.rbs +26 -0
  94. data/sig/generated/ruboty/ai_agent/commands.rbs +13 -0
  95. data/sig/generated/ruboty/ai_agent/database/query_methods.rbs +39 -0
  96. data/sig/generated/ruboty/ai_agent/database.rbs +27 -0
  97. data/sig/generated/ruboty/ai_agent/global_settings.rbs +23 -0
  98. data/sig/generated/ruboty/ai_agent/http_mcp_client.rbs +62 -0
  99. data/sig/generated/ruboty/ai_agent/llm/openai/model.rbs +21 -0
  100. data/sig/generated/ruboty/ai_agent/llm/openai.rbs +54 -0
  101. data/sig/generated/ruboty/ai_agent/llm/response.rbs +29 -0
  102. data/sig/generated/ruboty/ai_agent/llm.rbs +9 -0
  103. data/sig/generated/ruboty/ai_agent/mcp_clients.rbs +24 -0
  104. data/sig/generated/ruboty/ai_agent/mcp_configuration.rbs +35 -0
  105. data/sig/generated/ruboty/ai_agent/record_set.rbs +42 -0
  106. data/sig/generated/ruboty/ai_agent/recordable.rbs +56 -0
  107. data/sig/generated/ruboty/ai_agent/token_usage.rbs +30 -0
  108. data/sig/generated/ruboty/ai_agent/tool.rbs +27 -0
  109. data/sig/generated/ruboty/ai_agent/user.rbs +35 -0
  110. data/sig/generated/ruboty/ai_agent/user_ai_memories.rbs +11 -0
  111. data/sig/generated/ruboty/ai_agent/user_associations.rbs +21 -0
  112. data/sig/generated/ruboty/ai_agent/user_mcp_caches.rbs +44 -0
  113. data/sig/generated/ruboty/ai_agent/user_mcp_client.rbs +58 -0
  114. data/sig/generated/ruboty/ai_agent/user_mcp_configurations.rbs +11 -0
  115. data/sig/generated/ruboty/ai_agent/user_mcp_tools_caches.rbs +11 -0
  116. data/sig/generated/ruboty/ai_agent/version.rbs +7 -0
  117. data/sig/generated/ruboty/ai_agent.rbs +9 -0
  118. data/sig/generated/ruboty/handlers/ai_agent.rbs +32 -0
  119. data/sig/generated-by-scripts/concerns.rbs +27 -0
  120. data/sig/generated-by-scripts/memorized_ivars.rbs +42 -0
  121. data/sig-lib/event_stream_parser/event_stream_parser.rbs +21 -0
  122. data/sig-lib/mem/mem.rbs +19 -0
  123. data/sig-lib/ruboty/ruboty.rbs +421 -0
  124. metadata +263 -0
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # ListAiCommands action for Ruboty::AiAgent
7
+ class ListAiCommands < Base
8
+ # @rbs override
9
+ def call
10
+ commands = Commands.builtins(message:, chat_thread:)
11
+
12
+ message.reply(
13
+ commands.flat_map(&:matchers).map { |matcher| "#{matcher.pattern.inspect} - #{matcher.description}" }.join("\n")
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # ListAiMemories action for Ruboty::AiAgent
7
+ class ListAiMemories < Base
8
+ def call
9
+ memories = (user.ai_memories.all || {}).map do |idx, memory|
10
+ "Memory #{idx}: #{memory}"
11
+ end.join("\n")
12
+
13
+ message.reply(memories.empty? ? 'No memories found.' : memories)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # ListMcp action for Ruboty::AiAgent
7
+ class ListMcp < Base
8
+ def call
9
+ mcp_configurations = (user.mcp_configurations.all || {}).map do |name, mcp_configuration|
10
+ "#{name}: #{mcp_configuration.to_h.except(:record_type).to_json}"
11
+ end.join("\n")
12
+
13
+ message.reply(mcp_configurations.empty? ? 'No memories found.' : mcp_configurations)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # RemoveAiCommand action for Ruboty::AiAgent
7
+ class RemoveAiCommand < Base
8
+ def call
9
+ message.reply("TODO: Implement RemoveAiCommand action for name: #{name_param}")
10
+ end
11
+
12
+ def name_param #: String
13
+ message[:name]
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # RemoveAiMemory action for Ruboty::AiAgent
7
+ class RemoveAiMemory < Base
8
+ def call
9
+ message.reply("TODO: Implement RemoveAiMemory action for index: #{index_param}")
10
+ if user.ai_memories.key?(index_param)
11
+ user.ai_memories.remove(index_param)
12
+
13
+ message.reply("Removed memory #{index_param}.")
14
+ else
15
+ message.reply("Memory #{index_param} does not exist.")
16
+ end
17
+ end
18
+
19
+ def index_param #: String
20
+ message[:index]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # RemoveMcp action for Ruboty::AiAgent
7
+ class RemoveMcp < Base
8
+ def call
9
+ if user.mcp_configurations.key?(name_param)
10
+ user.mcp_configurations.remove(name_param)
11
+
12
+ message.reply("Removed MCP configuration #{name_param}.")
13
+ else
14
+ message.reply("MCP configuration #{name_param} does not exist.")
15
+ end
16
+ end
17
+
18
+ def name_param #: String
19
+ message[:name]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # SetSystemPrompt action for Ruboty::AiAgent
7
+ class SetSystemPrompt < Base
8
+ def call
9
+ case scope_param.to_sym
10
+ when :user
11
+ user.system_prompt = prompt_param
12
+ message.reply("Set user system prompt: #{prompt_param}")
13
+ when :global
14
+ database.global_settings.system_prompt = prompt_param
15
+ message.reply("Set global system prompt: #{prompt_param}")
16
+ else
17
+ message.reply("Error: Invalid scope '#{scope_param}'. Use 'user' or 'global'.")
18
+ end
19
+ end
20
+
21
+ def prompt_param #: String
22
+ message[:prompt]
23
+ end
24
+
25
+ def scope_param #: String
26
+ message[:scope] || 'user'
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Actions
6
+ # ShowSystemPrompt action for Ruboty::AiAgent
7
+ class ShowSystemPrompt < Base
8
+ def call
9
+ user_prompt = user.system_prompt
10
+ global_prompt = database.global_settings.system_prompt
11
+
12
+ reply = []
13
+ reply << if user_prompt
14
+ "User system prompt: #{user_prompt}"
15
+ else
16
+ 'User system prompt is not set.'
17
+ end
18
+
19
+ reply << if global_prompt
20
+ "Global system prompt: #{global_prompt}"
21
+ else
22
+ 'Global system prompt is not set.'
23
+ end
24
+
25
+ message.reply(reply.join("\n"))
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ # Ruboty AI Agent actions.
6
+ module Actions
7
+ autoload :AddAiCommand, 'ruboty/ai_agent/actions/add_ai_command'
8
+ autoload :AddAiMemory, 'ruboty/ai_agent/actions/add_ai_memory'
9
+ autoload :AddMcp, 'ruboty/ai_agent/actions/add_mcp'
10
+ autoload :Base, 'ruboty/ai_agent/actions/base'
11
+ autoload :Chat, 'ruboty/ai_agent/actions/chat'
12
+ autoload :ListAiCommands, 'ruboty/ai_agent/actions/list_ai_commands'
13
+ autoload :ListAiMemories, 'ruboty/ai_agent/actions/list_ai_memories'
14
+ autoload :ListMcp, 'ruboty/ai_agent/actions/list_mcp'
15
+ autoload :RemoveAiCommand, 'ruboty/ai_agent/actions/remove_ai_command'
16
+ autoload :RemoveAiMemory, 'ruboty/ai_agent/actions/remove_ai_memory'
17
+ autoload :RemoveMcp, 'ruboty/ai_agent/actions/remove_mcp'
18
+ autoload :SetSystemPrompt, 'ruboty/ai_agent/actions/set_system_prompt'
19
+ autoload :ShowSystemPrompt, 'ruboty/ai_agent/actions/show_system_prompt'
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ # Agent class to interact with LLM and manage conversations.
6
+ class Agent
7
+ attr_reader :llm #: LLM::OpenAI
8
+ attr_reader :messages #: Array[ChatMessage]
9
+ attr_reader :tools #: Array[Tool]
10
+
11
+ # @rbs llm: LLM::OpenAI
12
+ # @rbs messages: Array[ChatMessage]
13
+ # @rbs tools: Array[Tool]
14
+ def initialize(
15
+ llm:,
16
+ messages: [],
17
+ tools: []
18
+ )
19
+ @llm = llm
20
+ @messages = messages
21
+ @tools = tools
22
+ end
23
+
24
+ def complete(&)
25
+ loop do
26
+ response = llm.complete(
27
+ messages:,
28
+ tools:
29
+ )
30
+ on_response(response, &)
31
+ on_new_message(response.message, &)
32
+
33
+ if response.tool
34
+ on_tool_call(tool: response.tool, tool_arguments: response.tool_arguments, &)
35
+ messages << response.message
36
+
37
+ tool_response = response.call_tool || 'no return value'
38
+ tool_response_message = ChatMessage.from_llm_response(
39
+ tool: response.tool,
40
+ tool_call_id: response.tool_call_id,
41
+ tool_arguments: response.tool_arguments,
42
+ tool_response:
43
+ )
44
+ on_tool_response(tool_response:, message: tool_response_message, &)
45
+ messages << tool_response_message
46
+ else
47
+ messages << response.message
48
+
49
+ return response
50
+ end
51
+ end
52
+ end
53
+
54
+ def on_new_message(message, &callback)
55
+ callback&.call({ type: :new_message, message: })
56
+ end
57
+
58
+ def on_tool_call(tool:, tool_arguments:, &callback)
59
+ callback&.call({ type: :tool_call, tool:, tool_arguments: })
60
+ end
61
+
62
+ def on_tool_response(tool_response:, message:, &callback)
63
+ callback&.call({ type: :tool_response, tool_response:, message: })
64
+ end
65
+
66
+ def on_response(response, &callback)
67
+ callback&.call({ type: :response, response: })
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Ruboty
6
+ module AiAgent
7
+ # Cache for MCP operations
8
+ # @rbs generic D < Object
9
+ class CachedValue
10
+ include Recordable
11
+
12
+ attr_reader :expires_at #: Time
13
+ attr_reader :data #: D
14
+
15
+ # @rbs data: D
16
+ # @rbs expires_at: Time
17
+ def initialize(data:, expires_at:)
18
+ @data = data
19
+ @expires_at = expires_at.is_a?(Time) ? expires_at.round : Time.parse(expires_at)
20
+ end
21
+
22
+ # @rbs return: Hash[Symbol, untyped]
23
+ def to_h
24
+ {
25
+ expires_at: expires_at.rfc2822,
26
+ data: data
27
+ }
28
+ end
29
+
30
+ # @rbs return: bool
31
+ def expired?
32
+ Time.now > expires_at
33
+ end
34
+
35
+ # @rbs return: bool
36
+ def valid?
37
+ !expired?
38
+ end
39
+
40
+ register_record_type :mcp_cache
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ # Save MCP configuration details.
6
+ class ChatMessage
7
+ include Recordable
8
+
9
+ register_record_type :chat_message
10
+
11
+ attr_reader :role #: Symbol
12
+ attr_reader :content #: String
13
+ attr_reader :tool_call_id #: String?
14
+ attr_reader :tool_name #: String?
15
+ attr_reader :tool_arguments #: Hash[Symbol | String, untyped]?
16
+ attr_reader :token_usage #: TokenUsage?
17
+
18
+ # @rbs role: Symbol
19
+ # @rbs content: String
20
+ # @rbs ?tool_call_id: String?
21
+ # @rbs ?tool_name: String?
22
+ # @rbs ?tool_arguments: Hash[Symbol | String, untyped]?
23
+ # @rbs ?token_usage: TokenUsage?
24
+ def initialize(role:, content:, tool_call_id: nil, tool_name: nil, tool_arguments: nil, token_usage: nil)
25
+ @role = role
26
+ @content = content
27
+ @tool_call_id = tool_call_id
28
+ @tool_name = tool_name
29
+ @tool_arguments = tool_arguments
30
+ @token_usage = token_usage
31
+ end
32
+
33
+ def to_h #: Hash[Symbol, untyped]
34
+ {
35
+ role:,
36
+ content:,
37
+ tool_call_id:,
38
+ tool_name:,
39
+ tool_arguments:,
40
+ token_usage:
41
+ }
42
+ end
43
+
44
+ def self.from_llm_response(
45
+ tool:,
46
+ tool_call_id:,
47
+ tool_arguments:,
48
+ tool_response:
49
+ ) #: ChatMessage
50
+ new(
51
+ role: :tool,
52
+ content: tool_response,
53
+ tool_name: tool.name,
54
+ tool_call_id:,
55
+ tool_arguments: tool_arguments
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ # Manage thread-specific data.
6
+ class ChatThread
7
+ attr_reader :database #: Ruboty::AiAgent::Database
8
+ attr_reader :id #: String
9
+
10
+ class << self
11
+ def find_or_create(database:, id:) #: ChatThread
12
+ new(database: database, id: id)
13
+ end
14
+ end
15
+
16
+ def initialize(database:, id:)
17
+ @database = database
18
+ @id = id
19
+ end
20
+
21
+ # @rbs %a{memorized}
22
+ def messages #: ChatThreadMessages
23
+ @messages ||= ChatThreadMessages.new(database: database, chat_thread_id: id)
24
+ end
25
+
26
+ def clear #: void
27
+ messages.clear
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ # A set of records for a specific thread.
6
+ # @rbs generic Record
7
+ class ChatThreadAssociations < RecordSet #[Record]
8
+ attr_reader :chat_thread_id #: String
9
+
10
+ def initialize(database:, chat_thread_id:)
11
+ super(database:)
12
+
13
+ @chat_thread_id = chat_thread_id
14
+ end
15
+
16
+ # @rbs!
17
+ # def self.association_key: () -> Symbol
18
+ # def self.association_key=: (Symbol) -> Symbol
19
+
20
+ # @rbs skip
21
+ class << self
22
+ attr_accessor :association_key
23
+ end
24
+
25
+ def association_key #: Symbol
26
+ self.class.association_key || raise(NotImplementedError, 'Subclasses must set the association_key method')
27
+ end
28
+
29
+ def namespace_keys
30
+ [:chat_threads, chat_thread_id, association_key]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ # Manage messages for the chat thread.
6
+ class ChatThreadMessages < ChatThreadAssociations #[ChatMessage]
7
+ self.association_key = :messages
8
+
9
+ # @rbs message: ChatMessage
10
+ def add(message) #: void
11
+ store(message, key: (keys.last.to_s.to_i || -1) + 1)
12
+ end
13
+
14
+ alias << add
15
+ end
16
+ end
17
+ 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 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
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Commands
6
+ # Clear histories of the chat thread.
7
+ class Clear < Base
8
+ on(%r{/clear}, name: 'clear', description: 'Clear the chat history.')
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
+ chat_thread.clear
24
+ message.reply('Cleared the chat history.')
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Commands
6
+ # Compact chat history by summarizing it
7
+ class Compact < Base
8
+ on(%r{/compact}, name: 'compact', description: 'Compact the chat history by summarizing it.')
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?
26
+ message.reply('No chat history to compact.')
27
+ return
28
+ end
29
+
30
+ summary = generate_summary(messages)
31
+
32
+ chat_thread.clear
33
+ chat_thread.messages.add(
34
+ ChatMessage.new(
35
+ role: :system,
36
+ content: "Previous conversation summary: #{summary}"
37
+ )
38
+ )
39
+
40
+ message.reply('Chat history has been compacted with a summary.')
41
+ rescue StandardError => e
42
+ if ENV['DEBUG']
43
+ message.reply("エラーが発生しました: #{e.full_message}")
44
+ else
45
+ message.reply("エラーが発生しました: #{e.message}")
46
+ end
47
+ 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
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruboty
4
+ module AiAgent
5
+ module Commands
6
+ # Show token usage information for the latest AI response
7
+ class Usage < Base
8
+ on(%r{/usage}, name: 'show_usage', description: 'Show token usage information for the latest AI response')
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
+ latest_message = chat_thread.messages.all_values.find(&:token_usage)
24
+
25
+ token_usage = latest_message&.token_usage
26
+
27
+ if token_usage
28
+ usage_text = "Token usage: #{format_number(token_usage.prompt_tokens)} (prompt) + #{format_number(token_usage.completion_tokens)} (completion) = #{format_number(token_usage.total_tokens)} (total)"
29
+
30
+ limit = token_usage.token_limit
31
+ if limit
32
+ usage_percentage = token_usage.usage_percentage
33
+ usage_text += " / #{format_number(limit)} (#{usage_percentage}%)"
34
+ end
35
+
36
+ message.reply(usage_text)
37
+ else
38
+ message.reply('No token usage information found.')
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # @rbs number: Integer
45
+ # @rbs return: String
46
+ def format_number(number)
47
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end