fantasy-cli 1.2.6

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 (112) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +456 -0
  4. data/bin/gsd +8 -0
  5. data/bin/gsd-core-darwin-amd64 +0 -0
  6. data/bin/gsd-core-darwin-arm64 +0 -0
  7. data/bin/gsd-core-linux-amd64 +0 -0
  8. data/bin/gsd-core-linux-arm64 +0 -0
  9. data/bin/gsd-core-windows-amd64.exe +0 -0
  10. data/bin/gsd-core-windows-arm64.exe +0 -0
  11. data/bin/gsd-core.exe +0 -0
  12. data/lib/gsd/agents/coordinator.rb +195 -0
  13. data/lib/gsd/agents/task_manager.rb +158 -0
  14. data/lib/gsd/agents/worker.rb +162 -0
  15. data/lib/gsd/agents.rb +30 -0
  16. data/lib/gsd/ai/chat.rb +486 -0
  17. data/lib/gsd/ai/cli.rb +248 -0
  18. data/lib/gsd/ai/command_parser.rb +97 -0
  19. data/lib/gsd/ai/commands/base.rb +42 -0
  20. data/lib/gsd/ai/commands/clear.rb +20 -0
  21. data/lib/gsd/ai/commands/context.rb +30 -0
  22. data/lib/gsd/ai/commands/cost.rb +30 -0
  23. data/lib/gsd/ai/commands/export.rb +42 -0
  24. data/lib/gsd/ai/commands/help.rb +61 -0
  25. data/lib/gsd/ai/commands/model.rb +67 -0
  26. data/lib/gsd/ai/commands/reset.rb +22 -0
  27. data/lib/gsd/ai/config.rb +256 -0
  28. data/lib/gsd/ai/context.rb +324 -0
  29. data/lib/gsd/ai/cost_tracker.rb +361 -0
  30. data/lib/gsd/ai/git_context.rb +169 -0
  31. data/lib/gsd/ai/history.rb +384 -0
  32. data/lib/gsd/ai/providers/anthropic.rb +429 -0
  33. data/lib/gsd/ai/providers/base.rb +282 -0
  34. data/lib/gsd/ai/providers/lmstudio.rb +279 -0
  35. data/lib/gsd/ai/providers/ollama.rb +336 -0
  36. data/lib/gsd/ai/providers/openai.rb +396 -0
  37. data/lib/gsd/ai/providers/openrouter.rb +429 -0
  38. data/lib/gsd/ai/reference_resolver.rb +225 -0
  39. data/lib/gsd/ai/repl.rb +349 -0
  40. data/lib/gsd/ai/streaming.rb +438 -0
  41. data/lib/gsd/ai/ui.rb +429 -0
  42. data/lib/gsd/buddy/cli.rb +284 -0
  43. data/lib/gsd/buddy/gacha.rb +148 -0
  44. data/lib/gsd/buddy/renderer.rb +108 -0
  45. data/lib/gsd/buddy/species.rb +190 -0
  46. data/lib/gsd/buddy/stats.rb +156 -0
  47. data/lib/gsd/buddy.rb +28 -0
  48. data/lib/gsd/cli.rb +455 -0
  49. data/lib/gsd/commands.rb +198 -0
  50. data/lib/gsd/config.rb +183 -0
  51. data/lib/gsd/error.rb +188 -0
  52. data/lib/gsd/frontmatter.rb +123 -0
  53. data/lib/gsd/go/bridge.rb +173 -0
  54. data/lib/gsd/history.rb +76 -0
  55. data/lib/gsd/milestone.rb +75 -0
  56. data/lib/gsd/output.rb +184 -0
  57. data/lib/gsd/phase.rb +102 -0
  58. data/lib/gsd/plugins/base.rb +92 -0
  59. data/lib/gsd/plugins/cli.rb +330 -0
  60. data/lib/gsd/plugins/config.rb +164 -0
  61. data/lib/gsd/plugins/hooks.rb +132 -0
  62. data/lib/gsd/plugins/installer.rb +158 -0
  63. data/lib/gsd/plugins/loader.rb +122 -0
  64. data/lib/gsd/plugins/manager.rb +187 -0
  65. data/lib/gsd/plugins/marketplace.rb +142 -0
  66. data/lib/gsd/plugins/sandbox.rb +114 -0
  67. data/lib/gsd/plugins/search.rb +131 -0
  68. data/lib/gsd/plugins/validator.rb +157 -0
  69. data/lib/gsd/plugins.rb +48 -0
  70. data/lib/gsd/profile.rb +127 -0
  71. data/lib/gsd/research.rb +85 -0
  72. data/lib/gsd/roadmap.rb +90 -0
  73. data/lib/gsd/skills/bundled/commit.md +58 -0
  74. data/lib/gsd/skills/bundled/debug.md +28 -0
  75. data/lib/gsd/skills/bundled/explain.md +41 -0
  76. data/lib/gsd/skills/bundled/plan.md +42 -0
  77. data/lib/gsd/skills/bundled/verify.md +26 -0
  78. data/lib/gsd/skills/loader.rb +189 -0
  79. data/lib/gsd/state.rb +102 -0
  80. data/lib/gsd/template.rb +106 -0
  81. data/lib/gsd/tools/ask_user_question.rb +179 -0
  82. data/lib/gsd/tools/base.rb +204 -0
  83. data/lib/gsd/tools/bash.rb +246 -0
  84. data/lib/gsd/tools/file_edit.rb +297 -0
  85. data/lib/gsd/tools/file_read.rb +199 -0
  86. data/lib/gsd/tools/file_write.rb +153 -0
  87. data/lib/gsd/tools/glob.rb +202 -0
  88. data/lib/gsd/tools/grep.rb +227 -0
  89. data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
  90. data/lib/gsd/tools/gsd_phase.rb +140 -0
  91. data/lib/gsd/tools/gsd_roadmap.rb +108 -0
  92. data/lib/gsd/tools/gsd_state.rb +143 -0
  93. data/lib/gsd/tools/gsd_template.rb +157 -0
  94. data/lib/gsd/tools/gsd_verify.rb +159 -0
  95. data/lib/gsd/tools/registry.rb +103 -0
  96. data/lib/gsd/tools/task.rb +235 -0
  97. data/lib/gsd/tools/todo_write.rb +290 -0
  98. data/lib/gsd/tools/web.rb +260 -0
  99. data/lib/gsd/tui/app.rb +366 -0
  100. data/lib/gsd/tui/auto_complete.rb +79 -0
  101. data/lib/gsd/tui/colors.rb +111 -0
  102. data/lib/gsd/tui/command_palette.rb +126 -0
  103. data/lib/gsd/tui/header.rb +38 -0
  104. data/lib/gsd/tui/input_box.rb +199 -0
  105. data/lib/gsd/tui/spinner.rb +40 -0
  106. data/lib/gsd/tui/status_bar.rb +51 -0
  107. data/lib/gsd/tui.rb +17 -0
  108. data/lib/gsd/validator.rb +216 -0
  109. data/lib/gsd/verify.rb +175 -0
  110. data/lib/gsd/version.rb +5 -0
  111. data/lib/gsd/workstream.rb +91 -0
  112. metadata +231 -0
@@ -0,0 +1,429 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ module Gsd
7
+ module AI
8
+ module Providers
9
+ # OpenRouter Provider - Integração com OpenRouter (múltiplos modelos)
10
+ #
11
+ # Suporta:
12
+ # - Qwen (gratuito)
13
+ # - Llama (gratuito)
14
+ # - Mistral (gratuito)
15
+ # - GPT-4, Claude, etc. (pagos)
16
+ # - 100+ modelos disponíveis
17
+ # - Streaming de respostas
18
+ # - Token counting preciso
19
+ class OpenRouter < Base
20
+ PROVIDER_NAME = 'OpenRouter'
21
+ DEFAULT_MODEL = 'qwen/qwen-2.5-72b-instruct'
22
+ API_URL = 'https://openrouter.ai/api/v1/chat/completions'
23
+ BASE_URL = 'https://openrouter.ai'
24
+
25
+ # Modelos gratuitos disponíveis
26
+ FREE_MODELS = [
27
+ 'qwen/qwen-3.6-plus-preview:free',
28
+ 'qwen/qwen-2.5-72b-instruct',
29
+ 'qwen/qwen-2-7b-instruct',
30
+ 'meta-llama/llama-3-8b-instruct',
31
+ 'meta-llama/llama-3-70b-instruct',
32
+ 'mistralai/mistral-7b-instruct',
33
+ 'google/gemma-7b-it'
34
+ ].freeze
35
+
36
+ # Modelos com preços
37
+ MODELS = {
38
+ 'qwen/qwen-3.6-plus-preview:free' => {
39
+ input_price: 0.0,
40
+ output_price: 0.0,
41
+ context_window: 1_000_000,
42
+ max_output: 8192,
43
+ vision: false,
44
+ caching: false,
45
+ latency: 0.92,
46
+ throughput: 33
47
+ },
48
+ 'qwen/qwen-2.5-72b-instruct' => {
49
+ input_price: 0.0,
50
+ output_price: 0.0,
51
+ context_window: 32_768,
52
+ max_output: 8192,
53
+ vision: false,
54
+ caching: false
55
+ },
56
+ 'qwen/qwen-2-7b-instruct' => {
57
+ input_price: 0.0,
58
+ output_price: 0.0,
59
+ context_window: 32_768,
60
+ max_output: 4096,
61
+ vision: false,
62
+ caching: false
63
+ },
64
+ 'meta-llama/llama-3-8b-instruct' => {
65
+ input_price: 0.0,
66
+ output_price: 0.0,
67
+ context_window: 8192,
68
+ max_output: 4096,
69
+ vision: false,
70
+ caching: false
71
+ },
72
+ 'meta-llama/llama-3-70b-instruct' => {
73
+ input_price: 0.0,
74
+ output_price: 0.0,
75
+ context_window: 8192,
76
+ max_output: 4096,
77
+ vision: false,
78
+ caching: false
79
+ },
80
+ 'mistralai/mistral-7b-instruct' => {
81
+ input_price: 0.0,
82
+ output_price: 0.0,
83
+ context_window: 32768,
84
+ max_output: 4096,
85
+ vision: false,
86
+ caching: false
87
+ },
88
+ 'google/gemma-7b-it' => {
89
+ input_price: 0.0,
90
+ output_price: 0.0,
91
+ context_window: 8192,
92
+ max_output: 4096,
93
+ vision: false,
94
+ caching: false
95
+ }
96
+ }.freeze
97
+
98
+ # Inicializa o provider OpenRouter
99
+ #
100
+ # @param model [String] Modelo do OpenRouter
101
+ # @param api_key [String] API key (ou use env OPENROUTER_API_KEY)
102
+ # @param debug [Boolean] Habilitar debug
103
+ def initialize(model: nil, api_key: nil, debug: false)
104
+ super(model: model, debug: debug)
105
+ @api_key = api_key || ENV['OPENROUTER_API_KEY']
106
+ @max_tokens = 4096
107
+ @token_cache = {}
108
+ @site_url = nil
109
+ @site_name = nil
110
+ end
111
+
112
+ # Configura informações do site (opcional, para ranking no OpenRouter)
113
+ #
114
+ # @param site_url [String] URL do site
115
+ # @param site_name [String] Nome do site
116
+ # @return [void]
117
+ def configure_site(site_url:, site_name:)
118
+ @site_url = site_url
119
+ @site_name = site_name
120
+ end
121
+
122
+ # Chama a API do OpenRouter
123
+ #
124
+ # @param system_prompt [String] System prompt
125
+ # @param messages [Array] Histórico de mensagens
126
+ # @param tools [Array] Tools disponíveis
127
+ # @param stream [Boolean] Habilitar streaming
128
+ # @yield [Hash] Chunk de streaming
129
+ # @return [OpenStruct] Resposta
130
+ def call(system_prompt:, messages:, tools:, stream: false, &block)
131
+ validate_api_key!
132
+
133
+ payload = build_payload(system_prompt, messages, tools, stream)
134
+
135
+ log_debug("Calling OpenRouter API with model: #{@model}")
136
+ log_debug("Messages count: #{messages.count}")
137
+
138
+ if stream
139
+ stream_request(payload, &block)
140
+ else
141
+ sync_request(payload)
142
+ end
143
+ rescue => e
144
+ log_debug("Error: #{e.message}")
145
+ raise OpenRouterError, "OpenRouter API error: #{e.message}"
146
+ end
147
+
148
+ # Verifica se está configurado
149
+ #
150
+ # @return [Boolean] true se configurado
151
+ def configured?
152
+ !@api_key.nil? && !@api_key.empty?
153
+ end
154
+
155
+ # Lista modelos disponíveis
156
+ #
157
+ # @return [Array<String>] Lista de modelos
158
+ def list_models
159
+ uri = URI("#{BASE_URL}/api/v1/models")
160
+ http = Net::HTTP.new(uri.host, uri.port)
161
+ http.use_ssl = true
162
+
163
+ request = Net::HTTP::Get.new(uri.path)
164
+ response = http.request(request)
165
+
166
+ if response.code == '200'
167
+ data = JSON.parse(response.body)
168
+ data['data']&.map { |m| m['id'] } || []
169
+ else
170
+ []
171
+ end
172
+ rescue => e
173
+ log_debug("Error listing models: #{e.message}")
174
+ []
175
+ end
176
+
177
+ # Verifica se modelo é gratuito
178
+ #
179
+ # @param model [String] Nome do modelo
180
+ # @return [Boolean] true se gratuito
181
+ def free_model?(model)
182
+ FREE_MODELS.include?(model)
183
+ end
184
+
185
+ # Retorna o preço do modelo
186
+ #
187
+ # @return [Hash] Preço de input e output
188
+ def pricing
189
+ MODELS[@model] || { input_price: 0, output_price: 0 }
190
+ end
191
+
192
+ private
193
+
194
+ # Valida API key
195
+ #
196
+ # @raise [ArgumentError] Se API key não estiver configurada
197
+ def validate_api_key!
198
+ raise ArgumentError, 'OPENROUTER_API_KEY not set' unless configured?
199
+ end
200
+
201
+ # Constrói o payload da requisição
202
+ #
203
+ # @param system_prompt [String] System prompt
204
+ # @param messages [Array] Mensagens
205
+ # @param tools [Array] Tools
206
+ # @param stream [Boolean] Streaming
207
+ # @return [Hash] Payload
208
+ def build_payload(system_prompt, messages, tools, stream)
209
+ formatted_messages = [{ role: 'system', content: system_prompt }] +
210
+ messages.map { |m| { role: m[:role], content: m[:content] } }
211
+
212
+ payload = {
213
+ model: @model,
214
+ max_tokens: @max_tokens,
215
+ messages: formatted_messages,
216
+ stream: stream
217
+ }
218
+
219
+ # Informações do site (opcional)
220
+ if @site_url && @site_name
221
+ payload[:site_url] = @site_url
222
+ payload[:site_name] = @site_name
223
+ end
224
+
225
+ # Tools (se suportado pelo modelo)
226
+ if tools.any?
227
+ payload[:tools] = format_tools(tools)
228
+ end
229
+
230
+ payload
231
+ end
232
+
233
+ # Formata tools para API do OpenRouter
234
+ #
235
+ # @param tools [Array] Tools
236
+ # @return [Array] Tools formatadas
237
+ def format_tools(tools)
238
+ tools.map do |tool|
239
+ {
240
+ type: 'function',
241
+ function: {
242
+ name: tool.name,
243
+ description: tool.description,
244
+ parameters: tool.input_schema || { type: 'object', properties: {} }
245
+ }
246
+ }
247
+ end
248
+ end
249
+
250
+ # Faz requisição síncrona
251
+ #
252
+ # @param payload [Hash] Payload da requisição
253
+ # @return [OpenStruct] Resposta
254
+ def sync_request(payload)
255
+ uri = URI(API_URL)
256
+ http = Net::HTTP.new(uri.host, uri.port)
257
+ http.use_ssl = true
258
+
259
+ request = Net::HTTP::Post.new(uri.path)
260
+ request['Content-Type'] = 'application/json'
261
+ request['Authorization'] = "Bearer #{@api_key}"
262
+ request['HTTP-Referer'] = @site_url || 'https://github.com/claude-ruby'
263
+ request['X-Title'] = @site_name || 'GSD Tools'
264
+ request.body = JSON.generate(payload)
265
+
266
+ log_debug("Sending request to #{API_URL}")
267
+
268
+ response = http.request(request)
269
+ log_debug("Response status: #{response.code}")
270
+
271
+ if response.code == '200'
272
+ parse_response(JSON.parse(response.body))
273
+ else
274
+ raise OpenRouterError, "API error: #{response.code} - #{response.body}"
275
+ end
276
+ end
277
+
278
+ # Faz requisição com streaming
279
+ #
280
+ # @param payload [Hash] Payload da requisição
281
+ # @yield [Hash] Chunk de streaming
282
+ # @return [OpenStruct] Resposta
283
+ def stream_request(payload, &block)
284
+ uri = URI(API_URL)
285
+ http = Net::HTTP.new(uri.host, uri.port)
286
+ http.use_ssl = true
287
+
288
+ request = Net::HTTP::Post.new(uri.path)
289
+ request['Content-Type'] = 'application/json'
290
+ request['Authorization'] = "Bearer #{@api_key}"
291
+ request['HTTP-Referer'] = @site_url || 'https://github.com/claude-ruby'
292
+ request['X-Title'] = @site_name || 'GSD Tools'
293
+ request.body = JSON.generate(payload)
294
+
295
+ log_debug("Sending streaming request to #{API_URL}")
296
+
297
+ content = +''
298
+ tool_calls = []
299
+ usage = {}
300
+
301
+ http.request(request) do |response|
302
+ if response.code == '200'
303
+ response.read_body do |chunk|
304
+ chunk.each_line do |line|
305
+ line = line.strip
306
+ next if line.empty?
307
+ next unless line.start_with?('data: ')
308
+
309
+ data = line[6..-1]
310
+ next if data == '[DONE]'
311
+
312
+ begin
313
+ event = JSON.parse(data)
314
+ yield(event) if block_given?
315
+
316
+ # Acumula conteúdo
317
+ choice = event.dig(:choices, 0)
318
+ if choice
319
+ delta = choice[:delta]
320
+ content << delta[:content].to_s if delta && delta[:content]
321
+
322
+ # Tool calls
323
+ if delta && delta[:tool_calls]
324
+ delta[:tool_calls].each do |tc|
325
+ if tc[:function]
326
+ tool_calls << {
327
+ id: tc[:id],
328
+ name: tc[:function][:name],
329
+ arguments: tc[:function][:arguments].to_s
330
+ }
331
+ end
332
+ end
333
+ end
334
+
335
+ # Usage (vem no último chunk)
336
+ if choice[:finish_reason] && event[:usage]
337
+ usage = extract_usage(event)
338
+ end
339
+ end
340
+ rescue JSON::ParserError
341
+ # Ignora linhas inválidas
342
+ end
343
+ end
344
+ end
345
+ else
346
+ raise OpenRouterError, "API error: #{response.code}"
347
+ end
348
+ end
349
+
350
+ OpenStruct.new(
351
+ content: content,
352
+ tool_calls: tool_calls,
353
+ usage: usage,
354
+ raw: {}
355
+ )
356
+ end
357
+
358
+ # Parseia a resposta da API
359
+ #
360
+ # @param data [Hash] Dados da resposta
361
+ # @return [OpenStruct] Resposta parseada
362
+ def parse_response(data)
363
+ content = extract_text(data)
364
+ tool_calls = extract_tool_calls(data)
365
+ usage = extract_usage(data)
366
+
367
+ OpenStruct.new(
368
+ content: content,
369
+ tool_calls: tool_calls,
370
+ usage: usage,
371
+ raw: data
372
+ )
373
+ end
374
+
375
+ # Extrai texto da resposta
376
+ #
377
+ # @param data [Hash] Dados da resposta
378
+ # @return [String] Texto extraído
379
+ def extract_text(data)
380
+ choices = data['choices'] || []
381
+ return '' if choices.empty?
382
+
383
+ message = choices[0]['message'] || {}
384
+ message['content'] || ''
385
+ end
386
+
387
+ # Extrai tool calls da resposta
388
+ #
389
+ # @param data [Hash] Dados da resposta
390
+ # @return [Array] Tool calls
391
+ def extract_tool_calls(data)
392
+ choices = data['choices'] || []
393
+ return [] if choices.empty?
394
+
395
+ message = choices[0]['message'] || {}
396
+ tool_calls = message['tool_calls'] || []
397
+
398
+ tool_calls.map do |tc|
399
+ function = tc['function'] || {}
400
+ OpenStruct.new(
401
+ id: tc['id'],
402
+ name: function['name'],
403
+ arguments: JSON.parse(function['arguments'] || '{}')
404
+ )
405
+ end
406
+ rescue => e
407
+ log_debug("Error extracting tool calls: #{e.message}")
408
+ []
409
+ end
410
+
411
+ # Extrai usage da resposta
412
+ #
413
+ # @param data [Hash] Dados da resposta
414
+ # @return [Hash] Usage data
415
+ def extract_usage(data)
416
+ usage = data['usage'] || {}
417
+ {
418
+ input_tokens: usage['prompt_tokens'] || 0,
419
+ output_tokens: usage['completion_tokens'] || 0,
420
+ total_tokens: usage['total_tokens'] || 0
421
+ }
422
+ end
423
+ end
424
+
425
+ # Erro customizado para OpenRouter
426
+ class OpenRouterError < StandardError; end
427
+ end
428
+ end
429
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Gsd
6
+ module AI
7
+ # ReferenceResolver - Resolve referências (@) para conteúdo
8
+ #
9
+ # Responsável por:
10
+ # - Ler arquivos (@arquivo.rb)
11
+ # - Listar pastas (@pasta/)
12
+ # - Converter imagens para base64 (@imagem.png)
13
+ # - Respeitar limites (max 5 arquivos, 100KB cada)
14
+ class ReferenceResolver
15
+ # Limite de arquivos por mensagem
16
+ MAX_FILES = 5
17
+
18
+ # Limite de tamanho por arquivo (bytes)
19
+ MAX_FILE_SIZE = 100 * 1024 # 100KB
20
+
21
+ # Extensões de imagem suportadas
22
+ IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp].freeze
23
+
24
+ # Inicializa o resolver
25
+ #
26
+ # @param cwd [String] Diretório base para resolver paths
27
+ # @param provider [Object] Provider para verificar suporte a imagens
28
+ def initialize(cwd: Dir.pwd, provider: nil)
29
+ @cwd = cwd
30
+ @provider = provider
31
+ end
32
+
33
+ # Resolve uma lista de referências
34
+ #
35
+ # @param references [Array<String>] Lista de @referências
36
+ # @return [Hash] { text: "expandido", images: [{...}], errors: [] }
37
+ def resolve(references)
38
+ return { text: '', images: [], errors: [] } if references.nil? || references.empty?
39
+
40
+ files = []
41
+ dirs = []
42
+ images = []
43
+ errors = []
44
+
45
+ references.first(MAX_FILES).each do |ref|
46
+ path = File.expand_path(ref, @cwd)
47
+
48
+ unless File.exist?(path)
49
+ errors << "Não encontrado: @#{ref}"
50
+ next
51
+ end
52
+
53
+ if File.directory?(path)
54
+ dirs << { ref: ref, path: path, content: list_directory(path) }
55
+ elsif image_file?(path)
56
+ images << resolve_image(ref, path, errors)
57
+ else
58
+ files << resolve_file(ref, path, errors)
59
+ end
60
+ end
61
+
62
+ # Ignora imagens se provider não suportar
63
+ images = [] unless provider_supports_vision?
64
+
65
+ {
66
+ text: build_text(files, dirs),
67
+ images: images.compact,
68
+ errors: errors
69
+ }
70
+ end
71
+
72
+ # Resolve uma única referência
73
+ #
74
+ # @param ref [String] Referência sem @
75
+ # @return [Hash] { type: :file|:dir|:image, content: ..., error: ... }
76
+ def resolve_one(ref)
77
+ path = File.expand_path(ref, @cwd)
78
+
79
+ return { type: :error, error: "Não encontrado: @#{ref}" } unless File.exist?(path)
80
+
81
+ if File.directory?(path)
82
+ { type: :dir, content: list_directory(path) }
83
+ elsif image_file?(path)
84
+ # Sempre retorna image type, mesmo se não tiver provider
85
+ encoded = encode_image(path)
86
+ if encoded[:error]
87
+ { type: :error, error: encoded[:error] }
88
+ else
89
+ { type: :image, content: encoded }
90
+ end
91
+ else
92
+ content = read_file(path)
93
+ return { type: :error, error: content[:error] } if content[:error]
94
+
95
+ { type: :file, content: content[:data] }
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def resolve_file(ref, path, errors)
102
+ content = read_file(path)
103
+ if content[:error]
104
+ errors << content[:error]
105
+ nil
106
+ else
107
+ { ref: ref, path: path, content: content[:data] }
108
+ end
109
+ end
110
+
111
+ def resolve_image(ref, path, errors)
112
+ return nil unless provider_supports_vision?
113
+
114
+ encoded = encode_image(path)
115
+ if encoded[:error]
116
+ errors << encoded[:error]
117
+ nil
118
+ else
119
+ { ref: ref, path: path, base64: encoded[:data], mime: encoded[:mime] }
120
+ end
121
+ end
122
+
123
+ def read_file(path)
124
+ size = File.size(path)
125
+ if size > MAX_FILE_SIZE
126
+ return { error: "Arquivo muito grande (>#{MAX_FILE_SIZE / 1024}KB): #{path}" }
127
+ end
128
+
129
+ { data: File.read(path) }
130
+ rescue => e
131
+ { error: "Erro ao ler #{path}: #{e.message}" }
132
+ end
133
+
134
+ def list_directory(path)
135
+ entries = Dir.entries(path).reject { |e| e.start_with?('.') }
136
+ files = entries.select { |e| File.file?(File.join(path, e)) }
137
+ dirs = entries.select { |e| File.directory?(File.join(path, e)) }
138
+
139
+ {
140
+ path: path,
141
+ files: files,
142
+ directories: dirs,
143
+ total: entries.count
144
+ }
145
+ rescue => e
146
+ { error: "Erro ao listar #{path}: #{e.message}" }
147
+ end
148
+
149
+ def image_file?(path)
150
+ ext = File.extname(path).downcase
151
+ IMAGE_EXTENSIONS.include?(ext)
152
+ end
153
+
154
+ def encode_image(path)
155
+ ext = File.extname(path).downcase
156
+ mime = case ext
157
+ when '.png' then 'image/png'
158
+ when '.jpg', '.jpeg' then 'image/jpeg'
159
+ when '.gif' then 'image/gif'
160
+ when '.webp' then 'image/webp'
161
+ when '.bmp' then 'image/bmp'
162
+ else 'application/octet-stream'
163
+ end
164
+
165
+ size = File.size(path)
166
+ if size > MAX_FILE_SIZE
167
+ return { error: "Imagem muito grande (>#{MAX_FILE_SIZE / 1024}KB): #{path}" }
168
+ end
169
+
170
+ data = Base64.strict_encode64(File.read(path, mode: 'rb'))
171
+ { data: data, mime: mime }
172
+ rescue => e
173
+ { error: "Erro ao codificar imagem #{path}: #{e.message}" }
174
+ end
175
+
176
+ def provider_supports_vision?
177
+ return false if @provider.nil?
178
+
179
+ # Verifica se provider tem método supports_vision? ou inferir do nome
180
+ if @provider.respond_to?(:supports_vision?)
181
+ @provider.supports_vision?
182
+ else
183
+ # Inferir do nome da classe
184
+ provider_name = @provider.class.name.downcase
185
+ provider_name.include?('anthropic') || provider_name.include?('openai')
186
+ end
187
+ end
188
+
189
+ def build_text(files, dirs)
190
+ parts = []
191
+
192
+ files.each do |f|
193
+ next if f.nil?
194
+
195
+ parts << <<~FILE
196
+ --- #{f[:ref]} ---
197
+ #{f[:content]}
198
+ ---
199
+ FILE
200
+ end
201
+
202
+ dirs.each do |d|
203
+ content = d[:content]
204
+ if content.nil? || (content.is_a?(Hash) && content[:error])
205
+ error_msg = content&.dig(:error) || 'Erro desconhecido'
206
+ parts << "--- #{d[:ref]} ---\n[Erro: #{error_msg}]\n---"
207
+ else
208
+ tree = []
209
+ tree << "Diretório: #{content[:path]}"
210
+ tree << "Subdiretórios: #{content[:directories].join(', ')}" if content[:directories]&.any?
211
+ tree << "Arquivos: #{content[:files].join(', ')}" if content[:files]&.any?
212
+
213
+ parts << <<~DIR
214
+ --- #{d[:ref]} ---
215
+ #{tree.join("\n")}
216
+ ---
217
+ DIR
218
+ end
219
+ end
220
+
221
+ parts.join("\n")
222
+ end
223
+ end
224
+ end
225
+ end