girb 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.
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conversation_history"
4
+ require_relative "providers/base"
5
+
6
+ module Girb
7
+ class AiClient
8
+ MAX_TOOL_ITERATIONS = 10
9
+
10
+ def initialize
11
+ @provider = Girb.configuration.provider!
12
+ end
13
+
14
+ def ask(question, context, binding: nil, line_no: nil)
15
+ @current_binding = binding
16
+ @current_line_no = line_no
17
+ @reasoning_log = []
18
+
19
+ prompt_builder = PromptBuilder.new(question, context)
20
+ @system_prompt = prompt_builder.system_prompt
21
+ user_message = prompt_builder.user_message
22
+
23
+ ConversationHistory.add_user_message(user_message)
24
+
25
+ tools = build_tools
26
+ process_with_tools(tools)
27
+ end
28
+
29
+ private
30
+
31
+ def build_tools
32
+ Tools.available_tools.map do |tool_class|
33
+ {
34
+ name: tool_class.tool_name,
35
+ description: tool_class.description,
36
+ parameters: tool_class.parameters
37
+ }
38
+ end
39
+ end
40
+
41
+ def process_with_tools(tools)
42
+ iterations = 0
43
+ accumulated_text = []
44
+
45
+ loop do
46
+ iterations += 1
47
+ if iterations > MAX_TOOL_ITERATIONS
48
+ puts "\n[girb] Tool iteration limit reached"
49
+ break
50
+ end
51
+
52
+ messages = ConversationHistory.to_normalized
53
+ response = @provider.chat(
54
+ messages: messages,
55
+ system_prompt: @system_prompt,
56
+ tools: tools
57
+ )
58
+
59
+ if Girb.configuration.debug
60
+ puts "[girb] function_calls: #{response.function_calls.inspect}"
61
+ puts "[girb] text: #{response.text&.slice(0, 100).inspect}"
62
+ puts "[girb] error: #{response.error.inspect}" if response.error
63
+ end
64
+
65
+ unless response
66
+ puts "[girb] Error: No response from API"
67
+ break
68
+ end
69
+
70
+ if response.error && !response.function_call?
71
+ puts "[girb] API Error: #{response.error}"
72
+ break
73
+ end
74
+
75
+ if response.function_call?
76
+ # Accumulate text that comes with function calls
77
+ if response.text && !response.text.empty?
78
+ accumulated_text << response.text
79
+ end
80
+
81
+ function_call = response.function_calls.first
82
+ tool_name = function_call[:name]
83
+ tool_args = function_call[:args] || {}
84
+
85
+ if Girb.configuration.debug
86
+ puts "[girb] Tool: #{tool_name}(#{tool_args.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')})"
87
+ end
88
+
89
+ result = execute_tool(tool_name, tool_args)
90
+
91
+ @reasoning_log << {
92
+ tool: tool_name,
93
+ args: tool_args,
94
+ result: result
95
+ }
96
+
97
+ # Record tool call and result in conversation history
98
+ ConversationHistory.add_tool_call(tool_name, tool_args, result)
99
+
100
+ if Girb.configuration.debug && result.is_a?(Hash) && result[:error]
101
+ puts "[girb] Tool error: #{result[:error]}"
102
+ end
103
+ else
104
+ # Text response
105
+ if response.text && !response.text.empty?
106
+ accumulated_text << response.text
107
+ end
108
+
109
+ if accumulated_text.any?
110
+ full_text = accumulated_text.join("\n")
111
+ puts full_text
112
+ ConversationHistory.add_assistant_message(full_text)
113
+ record_ai_response(full_text)
114
+ elsif Girb.configuration.debug
115
+ puts "[girb] Warning: Empty or unexpected response"
116
+ end
117
+ break
118
+ end
119
+ end
120
+ end
121
+
122
+ def execute_tool(tool_name, args)
123
+ tool_class = Tools.find_tool(tool_name)
124
+
125
+ unless tool_class
126
+ return { error: "Unknown tool: #{tool_name}" }
127
+ end
128
+
129
+ tool = tool_class.new
130
+ symbolized_args = args.transform_keys(&:to_sym)
131
+
132
+ if @current_binding
133
+ tool.execute(@current_binding, **symbolized_args)
134
+ else
135
+ { error: "No binding available for tool execution" }
136
+ end
137
+ rescue StandardError => e
138
+ { error: "Tool execution failed: #{e.class} - #{e.message}" }
139
+ end
140
+
141
+ def record_ai_response(response)
142
+ return unless @current_line_no
143
+
144
+ reasoning = @reasoning_log.empty? ? nil : format_reasoning
145
+ SessionHistory.record_ai_response(@current_line_no, response, reasoning)
146
+ end
147
+
148
+ def format_reasoning
149
+ @reasoning_log.map do |log|
150
+ args_str = log[:args].map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
151
+ result_str = log[:result].inspect
152
+ result_str = result_str[0, 500] + "..." if result_str.length > 500
153
+ "Tool: #{log[:tool]}(#{args_str})\nResult: #{result_str}"
154
+ end.join("\n\n")
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ class Configuration
5
+ attr_accessor :provider, :debug, :custom_prompt
6
+
7
+ def initialize
8
+ @provider = nil
9
+ @debug = ENV["GIRB_DEBUG"] == "1"
10
+ @custom_prompt = nil
11
+ end
12
+
13
+ # Get the configured provider, or raise an error if not configured
14
+ def provider!
15
+ return @provider if @provider
16
+
17
+ raise ConfigurationError, <<~MSG
18
+ No LLM provider configured.
19
+
20
+ Install a provider gem and set GIRB_PROVIDER environment variable:
21
+
22
+ gem install girb-ruby_llm
23
+ export GIRB_PROVIDER=girb-ruby_llm
24
+
25
+ Or implement your own provider:
26
+
27
+ class MyProvider < Girb::Providers::Base
28
+ def chat(messages:, system_prompt:, tools:)
29
+ # Your implementation
30
+ end
31
+ end
32
+
33
+ Girb.configure do |c|
34
+ c.provider = MyProvider.new
35
+ end
36
+ MSG
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session_history"
4
+
5
+ module Girb
6
+ class ContextBuilder
7
+ MAX_INSPECT_LENGTH = 500
8
+
9
+ def initialize(binding, irb_context = nil)
10
+ @binding = binding
11
+ @irb_context = irb_context
12
+ end
13
+
14
+ def build
15
+ {
16
+ source_location: capture_source_location,
17
+ local_variables: capture_locals,
18
+ instance_variables: capture_instance_variables,
19
+ class_variables: capture_class_variables,
20
+ global_variables: capture_global_variables,
21
+ self_info: capture_self,
22
+ last_value: capture_last_value,
23
+ last_exception: ExceptionCapture.last_exception,
24
+ session_history: session_history_with_line_numbers,
25
+ method_definitions: session_method_definitions
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def capture_source_location
32
+ loc = @binding.source_location
33
+ return nil unless loc
34
+
35
+ file, line = loc
36
+ {
37
+ file: file,
38
+ line: line
39
+ }
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def capture_locals
45
+ @binding.local_variables.to_h do |var|
46
+ value = @binding.local_variable_get(var)
47
+ [var, safe_inspect(value)]
48
+ end
49
+ end
50
+
51
+ def capture_instance_variables
52
+ obj = @binding.receiver
53
+ obj.instance_variables.to_h do |var|
54
+ value = obj.instance_variable_get(var)
55
+ [var, safe_inspect(value)]
56
+ end
57
+ rescue StandardError
58
+ {}
59
+ end
60
+
61
+ def capture_class_variables
62
+ obj = @binding.receiver
63
+ klass = obj.is_a?(Class) ? obj : obj.class
64
+ klass.class_variables.to_h do |var|
65
+ value = klass.class_variable_get(var)
66
+ [var, safe_inspect(value)]
67
+ end
68
+ rescue StandardError
69
+ {}
70
+ end
71
+
72
+ def capture_global_variables
73
+ # よく使われる重要なグローバル変数のみ収集(全部は多すぎる)
74
+ important_globals = %i[$! $@ $~ $& $` $' $+ $1 $2 $3 $stdin $stdout $stderr $DEBUG $VERBOSE $LOAD_PATH $LOADED_FEATURES]
75
+ important_globals.each_with_object({}) do |var, hash|
76
+ next unless global_variables.include?(var)
77
+
78
+ value = eval(var.to_s) # rubocop:disable Security/Eval
79
+ hash[var] = safe_inspect(value) unless value.nil?
80
+ rescue StandardError
81
+ next
82
+ end
83
+ end
84
+
85
+ def capture_self
86
+ obj = @binding.receiver
87
+ {
88
+ class: obj.class.name,
89
+ inspect: safe_inspect(obj),
90
+ methods: obj.methods(false).first(20)
91
+ }
92
+ end
93
+
94
+ def capture_last_value
95
+ return nil unless @irb_context
96
+
97
+ safe_inspect(@irb_context.last_value)
98
+ end
99
+
100
+ def safe_inspect(obj, max_length: MAX_INSPECT_LENGTH)
101
+ if defined?(ActiveRecord::Base) && obj.is_a?(ActiveRecord::Base)
102
+ return inspect_active_record(obj)
103
+ end
104
+
105
+ result = obj.inspect
106
+ result.length > max_length ? "#{result[0, max_length]}..." : result
107
+ rescue StandardError => e
108
+ "#<#{obj.class} (inspect failed: #{e.message})>"
109
+ end
110
+
111
+ def inspect_active_record(obj)
112
+ {
113
+ class: obj.class.name,
114
+ id: obj.try(:id),
115
+ attributes: obj.attributes,
116
+ new_record: obj.new_record?,
117
+ changed: obj.changed?,
118
+ errors: obj.errors.full_messages
119
+ }
120
+ end
121
+
122
+ def session_history_with_line_numbers
123
+ SessionHistory.all_with_line_numbers
124
+ rescue StandardError
125
+ []
126
+ end
127
+
128
+ def session_method_definitions
129
+ SessionHistory.method_index
130
+ rescue StandardError
131
+ []
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ # AI会話履歴をchat API形式で管理するクラス
5
+ class ConversationHistory
6
+ Message = Struct.new(:role, :content, :tool_calls, :tool_results, keyword_init: true)
7
+
8
+ class << self
9
+ def instance
10
+ @instance ||= new
11
+ end
12
+
13
+ def reset!
14
+ @instance = new
15
+ end
16
+
17
+ def add_user_message(content)
18
+ instance.add_user_message(content)
19
+ end
20
+
21
+ def add_assistant_message(content)
22
+ instance.add_assistant_message(content)
23
+ end
24
+
25
+ def add_tool_call(tool_name, args, result)
26
+ instance.add_tool_call(tool_name, args, result)
27
+ end
28
+
29
+ def to_contents
30
+ instance.to_contents
31
+ end
32
+
33
+ def messages
34
+ instance.messages
35
+ end
36
+
37
+ def clear!
38
+ instance.clear!
39
+ end
40
+
41
+ def summary
42
+ instance.summary
43
+ end
44
+
45
+ def to_normalized
46
+ instance.to_normalized
47
+ end
48
+ end
49
+
50
+ attr_reader :messages
51
+
52
+ def initialize
53
+ @messages = []
54
+ @pending_tool_calls = []
55
+ end
56
+
57
+ def add_user_message(content)
58
+ @messages << Message.new(role: "user", content: content)
59
+ end
60
+
61
+ def add_assistant_message(content)
62
+ # ツール呼び出しがあった場合は、それも含める
63
+ if @pending_tool_calls.any?
64
+ @messages << Message.new(
65
+ role: "model",
66
+ content: content,
67
+ tool_calls: @pending_tool_calls.dup
68
+ )
69
+ @pending_tool_calls.clear
70
+ else
71
+ @messages << Message.new(role: "model", content: content)
72
+ end
73
+ end
74
+
75
+ def add_tool_call(tool_name, args, result)
76
+ @pending_tool_calls << {
77
+ name: tool_name,
78
+ args: args,
79
+ result: result
80
+ }
81
+ end
82
+
83
+ def clear!
84
+ @messages.clear
85
+ @pending_tool_calls.clear
86
+ end
87
+
88
+ # Gemini API の contents 形式に変換(後方互換性のため残す)
89
+ def to_contents
90
+ @messages.map do |msg|
91
+ {
92
+ role: msg.role,
93
+ parts: [{ text: msg.content }]
94
+ }
95
+ end
96
+ end
97
+
98
+ # Provider-agnostic normalized format
99
+ def to_normalized
100
+ result = []
101
+
102
+ @messages.each do |msg|
103
+ role = msg.role == "model" ? :assistant : :user
104
+ result << { role: role, content: msg.content }
105
+
106
+ # Add tool calls and results if present
107
+ msg.tool_calls&.each do |tc|
108
+ result << { role: :tool_call, name: tc[:name], args: tc[:args] }
109
+ result << { role: :tool_result, name: tc[:name], result: tc[:result] }
110
+ end
111
+ end
112
+
113
+ # Add pending tool calls
114
+ @pending_tool_calls.each do |tc|
115
+ result << { role: :tool_call, name: tc[:name], args: tc[:args] }
116
+ result << { role: :tool_result, name: tc[:name], result: tc[:result] }
117
+ end
118
+
119
+ result
120
+ end
121
+
122
+ # 会話履歴のサマリー(デバッグ用)
123
+ def summary
124
+ @messages.map do |msg|
125
+ role_label = msg.role == "user" ? "USER" : "AI"
126
+ content_preview = msg.content.to_s[0, 50]
127
+ content_preview += "..." if msg.content.to_s.length > 50
128
+ "#{role_label}: #{content_preview}"
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ module ExceptionCapture
5
+ @last_exception = nil
6
+ @last_exception_binding = nil
7
+ @trace_point = nil
8
+
9
+ class << self
10
+ attr_reader :last_exception, :last_exception_binding
11
+
12
+ def capture(exception, binding = nil)
13
+ @last_exception = {
14
+ class: exception.class.name,
15
+ message: exception.message,
16
+ backtrace: exception.backtrace&.first(10),
17
+ time: Time.now
18
+ }
19
+ @last_exception_binding = binding
20
+ end
21
+
22
+ def clear
23
+ @last_exception = nil
24
+ @last_exception_binding = nil
25
+ end
26
+
27
+ def install
28
+ return if @trace_point
29
+
30
+ @trace_point = TracePoint.new(:raise) do |tp|
31
+ # IRB内部・Ruby内部の例外は除外
32
+ next if tp.path&.include?("irb")
33
+ next if tp.path&.include?("error_highlight")
34
+ next if tp.path&.include?("reline")
35
+ next if tp.path&.include?("readline")
36
+ next if tp.path&.include?("rdoc")
37
+ next if tp.path&.include?("/ri/")
38
+ next if tp.raised_exception.is_a?(SystemExit)
39
+ next if tp.raised_exception.is_a?(Interrupt)
40
+ # ErrorHighlight内部の例外を除外
41
+ next if tp.raised_exception.class.name&.start_with?("ErrorHighlight::")
42
+
43
+ capture(tp.raised_exception, tp.binding)
44
+ end
45
+ @trace_point.enable
46
+ end
47
+
48
+ def uninstall
49
+ @trace_point&.disable
50
+ @trace_point = nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "irb"
4
+ require "irb/command"
5
+ require_relative "exception_capture"
6
+ require_relative "context_builder"
7
+ require_relative "session_history"
8
+ require_relative "ai_client"
9
+
10
+ module Girb
11
+ # AI送信フラグ(スレッドローカル)
12
+ def self.ai_send_pending?
13
+ Thread.current[:girb_ai_send_pending]
14
+ end
15
+
16
+ def self.ai_send_pending=(value)
17
+ Thread.current[:girb_ai_send_pending] = value
18
+ end
19
+
20
+ module IrbIntegration
21
+ def self.setup
22
+ # コマンドを登録
23
+ require_relative "../irb/command/qq"
24
+
25
+ # 例外キャプチャのインストール
26
+ ExceptionCapture.install
27
+
28
+ # Ctrl+Space でAI送信するフックをインストール
29
+ install_eval_hook
30
+
31
+ # Ctrl+Space キーバインドをインストール
32
+ install_ai_keybinding
33
+ end
34
+
35
+ def self.install_eval_hook
36
+ IRB::Context.prepend(EvalHook)
37
+ end
38
+
39
+ def self.install_ai_keybinding
40
+ return unless defined?(Reline)
41
+
42
+ Reline::LineEditor.prepend(GirbLineEditorExtension)
43
+
44
+ # Ctrl+Space (ASCII 0) にバインド
45
+ Reline.core.config.add_default_key_binding_by_keymap(:emacs, [0], :girb_send_to_ai)
46
+ Reline.core.config.add_default_key_binding_by_keymap(:vi_insert, [0], :girb_send_to_ai)
47
+ end
48
+ end
49
+
50
+ module GirbLineEditorExtension
51
+ def girb_send_to_ai(_key)
52
+ Girb.ai_send_pending = true
53
+ finish
54
+ end
55
+ end
56
+
57
+ module EvalHook
58
+ def evaluate_expression(code, line_no)
59
+ code = code.to_s
60
+
61
+ # Ctrl+Space でAI送信された場合
62
+ if Girb.ai_send_pending?
63
+ Girb.ai_send_pending = false
64
+ question = code.strip
65
+ return if question.empty?
66
+
67
+ SessionHistory.record(line_no, question, is_ai_question: true)
68
+ ask_ai(question, line_no)
69
+ return
70
+ end
71
+
72
+ # 通常のRubyコード実行時はセッション履歴に記録
73
+ SessionHistory.record(line_no, code)
74
+ super
75
+ end
76
+
77
+ private
78
+
79
+ def ask_ai(question, line_no)
80
+ context = ContextBuilder.new(workspace.binding, self).build
81
+ client = AiClient.new
82
+ client.ask(question, context, binding: workspace.binding, line_no: line_no)
83
+ rescue StandardError => e
84
+ puts "[girb] Error: #{e.message}"
85
+ end
86
+ end
87
+ end