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,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ class PromptBuilder
5
+ SYSTEM_PROMPT = <<~PROMPT
6
+ You are girb, an AI assistant embedded in a Ruby developer's IRB session.
7
+
8
+ ## CRITICAL: Prompt Information Takes Highest Priority
9
+ Information in this system prompt and "User-Defined Instructions" section
10
+ takes precedence over tool results or user input.
11
+ When asked about environment or preconditions, check this prompt first.
12
+ Always verify if the information is already stated here before attempting programmatic detection.
13
+
14
+ ## Language
15
+ Respond in the same language the user is using. Detect the user's language from their question and match it.
16
+
17
+ ## Important: Understand the IRB Session Context
18
+ The user is interactively executing code in IRB and asking questions within that flow.
19
+ "Session History" contains the code the user has executed and past AI conversations in chronological order.
20
+ Always interpret questions in the context of this history.
21
+
22
+ For example, if the history shows:
23
+ 1: a = 1
24
+ 2: b = 2
25
+ 3: [USER] What will z be if I continue with c = 3 and beyond?
26
+ The user is asking about the value of z when continuing the pattern a=1, b=2, c=3... (answer: z=26).
27
+
28
+ ## Your Role
29
+ - Strive to understand the user's true intent and background
30
+ - Don't just answer the question; understand what they're trying to achieve and what challenges they face
31
+ - Analyze session history to understand what the user is trying to do
32
+ - Utilize the current execution context (variables, object state, exceptions)
33
+ - Provide specific, practical answers to questions
34
+ - Use tools to execute and verify code as needed
35
+
36
+ ## You May Ask Clarifying Questions
37
+ When you have doubts, ask the user about preconditions or unclear points.
38
+ - When multiple interpretations are possible: Confirm which interpretation is correct
39
+ - When preconditions are unclear: Ask what they're aiming for, what environment they're assuming
40
+ - When information is insufficient: Prompt for the full error message or related code
41
+ Asking questions increases dialogue turns but reduces misunderstandings and enables more accurate answers.
42
+
43
+ ## Response Guidelines
44
+ - Keep responses concise and practical
45
+ - Read patterns and intentions; handle hypothetical questions
46
+ - Code examples should use variables and objects from the current IRB context and be directly executable by pasting into IRB
47
+
48
+ ## Debugging Support on Errors
49
+ When users encounter errors, actively support debugging.
50
+ - Don't just point out the cause; show debugging steps to resolve it
51
+ - Suggest ways to inspect related code (e.g., using the inspect_object tool)
52
+ - Guide them step-by-step toward writing more robust code
53
+
54
+ ## Available Tools
55
+ Use tools to inspect variables in detail, retrieve source code, and execute code.
56
+ Actively use the evaluate_code tool especially for verifying hypotheses and calculations.
57
+ PROMPT
58
+
59
+ def initialize(question, context)
60
+ @question = question
61
+ @context = context
62
+ end
63
+
64
+ # Legacy single prompt format (for backward compatibility)
65
+ def build
66
+ <<~PROMPT
67
+ #{system_prompt}
68
+
69
+ #{build_context_section}
70
+
71
+ ## Question
72
+ #{@question}
73
+ PROMPT
74
+ end
75
+
76
+ # System prompt (shared across conversation)
77
+ def system_prompt
78
+ custom = Girb.configuration&.custom_prompt
79
+ if custom && !custom.empty?
80
+ "#{SYSTEM_PROMPT}\n\n## User-Defined Instructions\n#{custom}"
81
+ else
82
+ SYSTEM_PROMPT
83
+ end
84
+ end
85
+
86
+ # User message (context + question)
87
+ def user_message
88
+ <<~MSG
89
+ ## Current IRB Context
90
+ #{build_context_section}
91
+
92
+ ## Question
93
+ #{@question}
94
+ MSG
95
+ end
96
+
97
+ private
98
+
99
+ def build_context_section
100
+ <<~CONTEXT
101
+ ### Session History (Previous IRB Inputs)
102
+ Below is the code the user has executed so far. The question is asked within this flow.
103
+ #{format_session_history}
104
+
105
+ ### Current Local Variables
106
+ #{format_locals}
107
+
108
+ ### Last Evaluation Result
109
+ #{@context[:last_value] || "(none)"}
110
+
111
+ ### Last Exception
112
+ #{format_exception}
113
+
114
+ ### Methods Defined in IRB
115
+ #{format_method_definitions}
116
+ CONTEXT
117
+ end
118
+
119
+ def format_source_location
120
+ loc = @context[:source_location]
121
+ return "(unknown)" unless loc
122
+
123
+ "File: #{loc[:file]}\nLine: #{loc[:line]}"
124
+ end
125
+
126
+ def format_locals
127
+ return "(none)" if @context[:local_variables].nil? || @context[:local_variables].empty?
128
+
129
+ @context[:local_variables].map do |name, value|
130
+ "- #{name}: #{value}"
131
+ end.join("\n")
132
+ end
133
+
134
+ def format_self_info
135
+ info = @context[:self_info]
136
+ return "(unknown)" unless info
137
+
138
+ lines = ["Class: #{info[:class]}"]
139
+ lines << "inspect: #{info[:inspect]}"
140
+ if info[:methods]&.any?
141
+ lines << "Defined methods: #{info[:methods].join(', ')}"
142
+ end
143
+ lines.join("\n")
144
+ end
145
+
146
+ def format_exception
147
+ exc = @context[:last_exception]
148
+ return "(none)" unless exc
149
+
150
+ <<~EXC
151
+ Class: #{exc[:class]}
152
+ Message: #{exc[:message]}
153
+ Time: #{exc[:time]}
154
+ Backtrace:
155
+ #{exc[:backtrace]&.map { |l| " #{l}" }&.join("\n")}
156
+ EXC
157
+ end
158
+
159
+ def format_session_history
160
+ history = @context[:session_history]
161
+ return "(none)" if history.nil? || history.empty?
162
+
163
+ history.join("\n")
164
+ end
165
+
166
+ def format_method_definitions
167
+ methods = @context[:method_definitions]
168
+ return "(none)" if methods.nil? || methods.empty?
169
+
170
+ methods.join("\n")
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ module Providers
5
+ # Base class for LLM providers
6
+ # Implement this class to add support for new LLM providers
7
+ #
8
+ # Example:
9
+ # class MyProvider < Girb::Providers::Base
10
+ # def chat(messages:, system_prompt:, tools:)
11
+ # # Call your LLM API
12
+ # # Return Response object
13
+ # end
14
+ # end
15
+ #
16
+ # Girb.configure do |c|
17
+ # c.provider = MyProvider.new(api_key: "...")
18
+ # end
19
+ #
20
+ class Base
21
+ # Send a chat request to the LLM
22
+ #
23
+ # @param messages [Array<Hash>] Conversation history in normalized format
24
+ # Each message has :role (:user, :assistant, :tool_call, :tool_result) and :content
25
+ # @param system_prompt [String] System prompt
26
+ # @param tools [Array<Hash>] Tool definitions in normalized format
27
+ # @return [Response] Response object with text and/or function_calls
28
+ def chat(messages:, system_prompt:, tools:)
29
+ raise NotImplementedError, "#{self.class}#chat must be implemented"
30
+ end
31
+
32
+ # Response object returned by chat method
33
+ class Response
34
+ attr_reader :text, :function_calls, :error, :raw_response
35
+
36
+ def initialize(text: nil, function_calls: nil, error: nil, raw_response: nil)
37
+ @text = text
38
+ @function_calls = function_calls || []
39
+ @error = error
40
+ @raw_response = raw_response
41
+ end
42
+
43
+ def function_call?
44
+ @function_calls.any?
45
+ end
46
+ end
47
+
48
+ # Normalized tool definition format
49
+ # Providers should convert this to their specific format
50
+ #
51
+ # Example:
52
+ # {
53
+ # name: "evaluate_code",
54
+ # description: "Execute Ruby code",
55
+ # parameters: {
56
+ # type: "object",
57
+ # properties: {
58
+ # code: { type: "string", description: "Ruby code to execute" }
59
+ # },
60
+ # required: ["code"]
61
+ # }
62
+ # }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ # IRBセッション中の全入力を行番号付きで管理し、
5
+ # メソッド定義を追跡するクラス
6
+ class SessionHistory
7
+ Entry = Struct.new(:line_no, :code, :method_definition, :is_ai_question, :ai_response, :ai_reasoning, keyword_init: true)
8
+ MethodDef = Struct.new(:name, :start_line, :end_line, :code, keyword_init: true)
9
+
10
+ class << self
11
+ def instance
12
+ @instance ||= new
13
+ end
14
+
15
+ def reset!
16
+ @instance = new
17
+ end
18
+
19
+ # 委譲メソッド
20
+ def record(line_no, code, is_ai_question: false)
21
+ instance.record(line_no, code, is_ai_question: is_ai_question)
22
+ end
23
+
24
+ def entries
25
+ instance.entries
26
+ end
27
+
28
+ def method_definitions
29
+ instance.method_definitions
30
+ end
31
+
32
+ def find_by_line(line_no)
33
+ instance.find_by_line(line_no)
34
+ end
35
+
36
+ def find_by_line_range(start_line, end_line)
37
+ instance.find_by_line_range(start_line, end_line)
38
+ end
39
+
40
+ def find_method(name)
41
+ instance.find_method(name)
42
+ end
43
+
44
+ def all_with_line_numbers
45
+ instance.all_with_line_numbers
46
+ end
47
+
48
+ def method_index
49
+ instance.method_index
50
+ end
51
+
52
+ def record_ai_response(line_no, response, reasoning = nil)
53
+ instance.record_ai_response(line_no, response, reasoning)
54
+ end
55
+
56
+ def get_ai_detail(line_no)
57
+ instance.get_ai_detail(line_no)
58
+ end
59
+
60
+ def ai_conversations
61
+ instance.ai_conversations
62
+ end
63
+ end
64
+
65
+ attr_reader :entries, :method_definitions
66
+
67
+ def initialize
68
+ @entries = []
69
+ @method_definitions = []
70
+ @pending_method = nil
71
+ end
72
+
73
+ # IRBからの入力を記録
74
+ def record(line_no, code, is_ai_question: false)
75
+ code = code.to_s.chomp
76
+
77
+ entry = Entry.new(line_no: line_no, code: code, method_definition: nil, is_ai_question: is_ai_question)
78
+
79
+ # メソッド定義の開始を検出
80
+ if code.match?(/^\s*def\s+\w+/)
81
+ @pending_method = {
82
+ start_line: line_no,
83
+ code_lines: [code]
84
+ }
85
+ elsif @pending_method
86
+ @pending_method[:code_lines] << code
87
+
88
+ # メソッド定義の終了を検出(簡易的なend検出)
89
+ if code.strip == "end" || code.match?(/^\s*end\s*$/)
90
+ method_name = extract_method_name(@pending_method[:code_lines].first)
91
+ full_code = @pending_method[:code_lines].join("\n")
92
+
93
+ method_def = MethodDef.new(
94
+ name: method_name,
95
+ start_line: @pending_method[:start_line],
96
+ end_line: line_no,
97
+ code: full_code
98
+ )
99
+ @method_definitions << method_def
100
+ entry.method_definition = method_def
101
+ @pending_method = nil
102
+ end
103
+ end
104
+
105
+ @entries << entry
106
+ end
107
+
108
+ # 特定の行番号のエントリを取得
109
+ def find_by_line(line_no)
110
+ @entries.find { |e| e.line_no == line_no }
111
+ end
112
+
113
+ # 行範囲のエントリを取得
114
+ def find_by_line_range(start_line, end_line)
115
+ @entries.select { |e| e.line_no >= start_line && e.line_no <= end_line }
116
+ end
117
+
118
+ # メソッド名でメソッド定義を検索
119
+ def find_method(name)
120
+ name = name.to_s
121
+ @method_definitions.find { |m| m.name == name }
122
+ end
123
+
124
+ # 全履歴を行番号付きで取得
125
+ def all_with_line_numbers
126
+ @entries.map do |e|
127
+ if e.is_ai_question
128
+ # AI会話は質問と回答をまとめて表示
129
+ response_preview = if e.ai_response
130
+ truncate(e.ai_response, 100)
131
+ else
132
+ "(回答待ち)"
133
+ end
134
+ "#{e.line_no}: [USER] #{e.code} => [AI] #{response_preview}"
135
+ else
136
+ "#{e.line_no}: #{e.code}"
137
+ end
138
+ end
139
+ end
140
+
141
+ def truncate(str, max_length)
142
+ str.length > max_length ? str[0, max_length] + "..." : str
143
+ end
144
+
145
+ # メソッド定義のインデックス(メソッド名: 行範囲)
146
+ def method_index
147
+ @method_definitions.map do |m|
148
+ if m.start_line == m.end_line
149
+ "#{m.name}: #{m.start_line}行目"
150
+ else
151
+ "#{m.name}: #{m.start_line}-#{m.end_line}行目"
152
+ end
153
+ end
154
+ end
155
+
156
+ # AI質問への回答と思考の過程を記録
157
+ def record_ai_response(line_no, response, reasoning = nil)
158
+ entry = find_by_line(line_no)
159
+ return unless entry
160
+
161
+ entry.ai_response = response
162
+ entry.ai_reasoning = reasoning
163
+ end
164
+
165
+ # 特定の行のAI詳細情報を取得
166
+ def get_ai_detail(line_no)
167
+ entry = find_by_line(line_no)
168
+ return nil unless entry&.ai_response
169
+
170
+ {
171
+ line_no: entry.line_no,
172
+ question: entry.code,
173
+ response: entry.ai_response,
174
+ reasoning: entry.ai_reasoning
175
+ }
176
+ end
177
+
178
+ # AI会話の一覧(質問と回答のみ、思考の過程は含まない)
179
+ def ai_conversations
180
+ @entries.select { |e| e.is_ai_question && e.ai_response }.map do |e|
181
+ {
182
+ line_no: e.line_no,
183
+ question: e.code,
184
+ response: e.ai_response
185
+ }
186
+ end
187
+ end
188
+
189
+ private
190
+
191
+ def extract_method_name(def_line)
192
+ # "def foo" や "def foo(bar)" からメソッド名を抽出
193
+ match = def_line.match(/def\s+(\w+[?!=]?)/)
194
+ match ? match[1] : "unknown"
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Girb
4
+ module Tools
5
+ class Base
6
+ class << self
7
+ def tool_name
8
+ name.split("::").last.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
9
+ end
10
+
11
+ def description
12
+ raise NotImplementedError, "#{name} must implement .description"
13
+ end
14
+
15
+ def parameters
16
+ raise NotImplementedError, "#{name} must implement .parameters"
17
+ end
18
+
19
+ def to_gemini_tool
20
+ {
21
+ name: tool_name,
22
+ description: description,
23
+ parameters: parameters
24
+ }
25
+ end
26
+
27
+ def available?
28
+ true
29
+ end
30
+ end
31
+
32
+ def execute(binding, **params)
33
+ raise NotImplementedError, "#{self.class.name} must implement #execute"
34
+ end
35
+
36
+ protected
37
+
38
+ def safe_inspect(obj, max_length: 1000)
39
+ if defined?(ActiveRecord::Base) && obj.is_a?(ActiveRecord::Base)
40
+ return inspect_active_record(obj)
41
+ end
42
+
43
+ result = obj.inspect
44
+ result.length > max_length ? "#{result[0, max_length]}..." : result
45
+ rescue StandardError => e
46
+ "#<#{obj.class} (inspect failed: #{e.message})>"
47
+ end
48
+
49
+ def inspect_active_record(obj)
50
+ {
51
+ class: obj.class.name,
52
+ id: obj.try(:id),
53
+ attributes: obj.attributes,
54
+ new_record: obj.new_record?,
55
+ changed: obj.changed?,
56
+ errors: obj.errors.full_messages
57
+ }.to_s
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Girb
6
+ module Tools
7
+ class EvaluateCode < Base
8
+ class << self
9
+ def description
10
+ "Execute arbitrary Ruby code in the current context and return the result. " \
11
+ "Use this to call methods, create objects, test conditions, or perform any Ruby operation."
12
+ end
13
+
14
+ def parameters
15
+ {
16
+ type: "object",
17
+ properties: {
18
+ code: {
19
+ type: "string",
20
+ description: "Ruby code to execute (e.g., 'user.valid?', 'Order.where(status: :pending).count', 'arr.map { |x| x * 2 }')"
21
+ }
22
+ },
23
+ required: ["code"]
24
+ }
25
+ end
26
+ end
27
+
28
+ def execute(binding, code:)
29
+ result = binding.eval(code)
30
+ {
31
+ code: code,
32
+ result: safe_inspect(result),
33
+ result_class: result.class.name,
34
+ success: true
35
+ }
36
+ rescue SyntaxError => e
37
+ { code: code, error: "Syntax error: #{e.message}", success: false }
38
+ rescue StandardError => e
39
+ { code: code, error: "#{e.class}: #{e.message}", backtrace: e.backtrace&.first(5), success: false }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Girb
6
+ module Tools
7
+ class FindFile < Base
8
+ MAX_RESULTS = 20
9
+
10
+ class << self
11
+ def description
12
+ "Find files in the application by name pattern. " \
13
+ "Useful for locating models, controllers, views, or any files. " \
14
+ "Supports glob patterns like '*.rb' or '**/*user*.rb'."
15
+ end
16
+
17
+ def parameters
18
+ {
19
+ type: "object",
20
+ properties: {
21
+ pattern: {
22
+ type: "string",
23
+ description: "File name pattern (glob). Examples: 'user.rb', '**/user*.rb', 'app/models/*.rb'"
24
+ },
25
+ directory: {
26
+ type: "string",
27
+ description: "Directory to search in (optional, defaults to app root)"
28
+ }
29
+ },
30
+ required: ["pattern"]
31
+ }
32
+ end
33
+ end
34
+
35
+ def execute(binding, pattern:, directory: nil)
36
+ base_dir = resolve_base_directory(directory)
37
+
38
+ unless Dir.exist?(base_dir)
39
+ return { error: "Directory not found: #{base_dir}" }
40
+ end
41
+
42
+ # パターンにディレクトリが含まれていなければ再帰検索
43
+ search_pattern = if pattern.include?("/") || pattern.include?("**/")
44
+ File.join(base_dir, pattern)
45
+ else
46
+ File.join(base_dir, "**", pattern)
47
+ end
48
+
49
+ files = Dir.glob(search_pattern).reject { |f| File.directory?(f) }
50
+
51
+ # 結果を制限
52
+ truncated = files.length > MAX_RESULTS
53
+ files = files.first(MAX_RESULTS)
54
+
55
+ # 相対パスに変換
56
+ relative_files = files.map { |f| f.sub("#{base_dir}/", "") }
57
+
58
+ {
59
+ pattern: pattern,
60
+ base_directory: base_dir,
61
+ files: relative_files,
62
+ count: relative_files.length,
63
+ truncated: truncated
64
+ }
65
+ rescue StandardError => e
66
+ { error: "#{e.class}: #{e.message}" }
67
+ end
68
+
69
+ private
70
+
71
+ def resolve_base_directory(directory)
72
+ if directory
73
+ return directory if directory.start_with?("/")
74
+
75
+ base = app_root
76
+ File.join(base, directory)
77
+ else
78
+ app_root
79
+ end
80
+ end
81
+
82
+ def app_root
83
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
84
+ Rails.root.to_s
85
+ elsif defined?(Bundler) && Bundler.respond_to?(:root) && Bundler.root
86
+ Bundler.root.to_s
87
+ else
88
+ Dir.pwd
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end