scout-ai 1.1.0 → 1.1.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc083461a140b5149d6965f5aa635e651015786cfe312f220a33967fa10c52cb
4
- data.tar.gz: 694ac0ff0b626d99ee993c77a1dba12022f9e7a545bbab67b94627a4698ade09
3
+ metadata.gz: aaebf68f2d4bcbd388e292dbdd536558e0b33daf744845bc20fd7bf92e3693f4
4
+ data.tar.gz: 46d9aafabb314aaca7056e2982d911a69c165da1dfc668fd0b3755e43bd2b3a5
5
5
  SHA512:
6
- metadata.gz: ef373e303af56478ecde91faf87093226936e3537b9cb3346192e230af43cc8350091e589080b7c4e32993bf0cbcc43ef380582a4d38b82ca5da33bc60754dfd
7
- data.tar.gz: cccb6e93d1b827a02f5b9159a11eb7839c11c48b0cdd5a0096644b19479e0609d9d641efff286a545625fc17863e2f106faa2ea775e7cde121f313260fdef779
6
+ metadata.gz: 3ce6e03f60080d5f103eddd7e14df23fe90ee8f446563084fbfc30eebfdabbcb193cc061b22058bf9560eb335586f678cdb81954726b83523030e9cd25d0e335
7
+ data.tar.gz: a6d7c13bf096be0b1947d64461acfaaa38ad94bafa33112b28bd167b832fcb16fa5b913fbd16699be058d67e7ebb38efee1322237ec8c90010fef73222b3f882
data/.vimproject CHANGED
@@ -6,6 +6,7 @@ scout-ai=$PWD filter="*.rb *.rake Rakefile *.rdoc *.R *.sh *.js *.haml *.sass *.
6
6
  scout-ai
7
7
  }
8
8
  chats=chats filter="*"{
9
+ parse
9
10
 
10
11
  test_tool
11
12
 
@@ -98,7 +99,6 @@ scout-ai=$PWD filter="*.rb *.rake Rakefile *.rdoc *.R *.sh *.js *.haml *.sass *.
98
99
  scout=scout{
99
100
  llm=llm{
100
101
  utils.rb
101
- parse.rb
102
102
  tools.rb
103
103
  tools=tools{
104
104
  mcp.rb
@@ -107,6 +107,17 @@ scout-ai=$PWD filter="*.rb *.rake Rakefile *.rdoc *.R *.sh *.js *.haml *.sass *.
107
107
  call.rb
108
108
  }
109
109
  chat.rb
110
+ chat=chat{
111
+ annotation.rb
112
+ parse.rb
113
+ process.rb
114
+ process=process{
115
+ tools.rb
116
+ files.rb
117
+ clear.rb
118
+ options.rb
119
+ }
120
+ }
110
121
 
111
122
  backends=backends{
112
123
  openai.rb
data/Rakefile CHANGED
@@ -18,7 +18,9 @@ Juwelier::Tasks.new do |gem|
18
18
  # dependencies defined in Gemfile
19
19
  gem.add_runtime_dependency 'scout-rig', '>= 0'
20
20
  gem.add_runtime_dependency 'ruby-openai', '>= 0'
21
+ gem.add_runtime_dependency 'ollama-ai', '>= 0'
21
22
  gem.add_runtime_dependency 'ruby-mcp-client', '>= 0'
23
+ gem.add_runtime_dependency 'hnswlib', '>= 0'
22
24
  end
23
25
  Juwelier::RubygemsDotOrgTasks.new
24
26
  require 'rake/testtask'
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.1.2
data/bin/scout-ai CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  $LOAD_PATH.unshift File.join(__dir__, '../lib')
4
4
 
5
+ if _i = ARGV.index("--log")
6
+ require 'scout/log'
7
+ log = ARGV[_i+1]
8
+ Log.severity = log.to_i
9
+ ARGV.delete "--log"
10
+ ARGV.delete log
11
+ end
12
+
13
+ dev_dir = nil
14
+ if _i = ARGV.index("--dev")
15
+ dev_dir = ARGV[_i+1]
16
+ ARGV.delete "--dev"
17
+ ARGV.delete dev_dir
18
+ end
19
+
20
+ if dev_dir.nil?
21
+ _s = nil
22
+ ARGV.each_with_index do |s,i|
23
+ if s.match(/^--dev(?:=(.*))?/)
24
+ dev_dir = $1
25
+ _s = s
26
+ next
27
+ end
28
+ end
29
+ ARGV.delete _s if _s
30
+ end
31
+
32
+ if dev_dir.nil? && ENV["SCOUT_DEV"]
33
+ dev_dir = ENV["SCOUT_DEV"]
34
+ ARGV.delete "--dev"
35
+ ARGV.delete dev_dir
36
+ end
37
+
38
+ if dev_dir
39
+ ['scout-*/lib'].each do |pattern|
40
+ Dir.glob(File.join(File.expand_path(dev_dir), pattern)).each do |f|
41
+ $LOAD_PATH.unshift f
42
+ end
43
+ end
44
+ ['rbbt-*/lib'].each do |pattern|
45
+ Dir.glob(File.join(File.expand_path(dev_dir), pattern)).each do |f|
46
+ $LOAD_PATH.unshift f
47
+ end
48
+ end
49
+ end
50
+
5
51
  require 'scout-ai'
6
52
 
7
53
  load Scout.bin.scout.find
@@ -28,8 +28,8 @@ module LLM
28
28
  end
29
29
 
30
30
 
31
- def chat(model = nil, options = {})
32
- response = ask(current_chat, model, options.merge(return_messages: true))
31
+ def chat(options = {})
32
+ response = ask(current_chat, options.merge(return_messages: true))
33
33
  if Array === response
34
34
  current_chat.concat(response)
35
35
  current_chat.answer
data/lib/scout/llm/ask.rb CHANGED
@@ -34,16 +34,24 @@ module LLM
34
34
 
35
35
  endpoint, persist = IndiferentHash.process_options options, :endpoint, :persist, persist: true
36
36
 
37
- endpoint ||= Scout::Config.get :endpoint, :ask, :llm, env: 'ASK_ENDPOINT,LLM_ENDPOINT'
37
+ endpoint ||= Scout::Config.get :endpoint, :ask, :llm, env: 'ASK_ENDPOINT,LLM_ENDPOINT,ENDPOINT,LLM,ASK'
38
38
  if endpoint && Scout.etc.AI[endpoint].exists?
39
39
  options = IndiferentHash.add_defaults options, Scout.etc.AI[endpoint].yaml
40
40
  elsif endpoint && endpoint != ""
41
41
  raise "Endpoint not found #{endpoint}"
42
42
  end
43
43
 
44
- Log.high Log.color :green, "Asking #{endpoint || 'client'}:\n" + LLM.print(messages)
44
+ if options[:backend].to_s == 'responses'
45
+ messages = Chat.clear(messages, 'previous_response_id')
46
+ else
47
+ messages = Chat.clean(messages, 'previous_response_id')
48
+ options.delete :previous_response_id
49
+ end
50
+
51
+ Log.high Log.color :green, "Asking #{endpoint || 'client'}: #{options[:previous_response_id]}\n" + LLM.print(messages)
45
52
  tools = options[:tools]
46
53
  Log.high "Tools: #{Log.fingerprint tools.keys}}" if tools
54
+ Log.debug "#{Log.fingerprint tools}}" if tools
47
55
 
48
56
  res = Persist.persist(endpoint, :json, prefix: "LLM ask", other: options.merge(messages: messages), persist: persist) do
49
57
  backend = IndiferentHash.process_options options, :backend
@@ -1,5 +1,3 @@
1
- require_relative '../parse'
2
- require_relative '../tools'
3
1
  require_relative '../chat'
4
2
 
5
3
  module LLM
@@ -1,7 +1,4 @@
1
1
  require 'ollama-ai'
2
- require_relative '../parse'
3
- require_relative '../tools'
4
- require_relative '../utils'
5
2
  require_relative '../chat'
6
3
 
7
4
  module LLM
@@ -58,8 +58,10 @@ module LLM
58
58
  model ||= LLM.get_url_config(:model, url, :openai_ask, :ask, :openai, env: 'OPENAI_MODEL', default: "gpt-4.1")
59
59
  end
60
60
 
61
- case format.to_sym
62
- when :json, :json_object
61
+ case format
62
+ when Hash
63
+ options[:response_format] = format
64
+ when 'json', 'json_object', :json, :json_object
63
65
  options[:response_format] = {type: 'json_object'}
64
66
  else
65
67
  options[:response_format] = {type: format}
@@ -1,9 +1,6 @@
1
- require 'scout'
2
1
  require 'openai'
3
2
  require 'rest-client'
4
- require_relative '../parse'
5
- require_relative '../tools'
6
- require_relative '../utils'
3
+ require_relative '../chat'
7
4
 
8
5
  module LLM
9
6
  module OpenWebUI
@@ -1,7 +1,5 @@
1
1
  require 'scout'
2
- require 'openai'
3
- require_relative '../parse'
4
- require_relative '../tools'
2
+ require_relative '../chat'
5
3
 
6
4
  module LLM
7
5
  module Relay
@@ -106,35 +106,44 @@ module LLM
106
106
  def self.process_input(messages)
107
107
  messages = self.tools_to_responses messages
108
108
 
109
- messages.collect do |message|
110
- IndiferentHash.setup(message)
111
- if message[:role] == 'image'
109
+ res = []
110
+ messages.each do |message|
111
+ message = IndiferentHash.setup(message)
112
+
113
+ role = message[:role]
114
+
115
+ case role.to_s
116
+ when 'image'
112
117
  path = message[:content]
113
118
  path = LLM.find_file path
114
119
  if Open.remote?(path)
115
- {role: :user, content: {type: :input_image, image_url: path }}
120
+ res << {role: :user, content: {type: :input_image, image_url: path }}
116
121
  elsif Open.exists?(path)
117
122
  path = self.encode_image(path)
118
- {role: :user, content: [{type: :input_image, image_url: path }]}
123
+ res << {role: :user, content: [{type: :input_image, image_url: path }]}
119
124
  else
120
- raise
125
+ raise "Image does not exist in #{path}"
121
126
  end
122
- elsif message[:role] == 'pdf'
127
+ when 'pdf'
123
128
  path = original_path = message[:content]
124
129
  if Open.remote?(path)
125
- {role: :user, content: {type: :input_file, file_url: path }}
130
+ res << {role: :user, content: {type: :input_file, file_url: path }}
126
131
  elsif Open.exists?(path)
127
132
  data = self.encode_pdf(path)
128
- {role: :user, content: [{type: :input_file, file_data: data, filename: File.basename(path) }]}
133
+ res << {role: :user, content: [{type: :input_file, file_data: data, filename: File.basename(path) }]}
129
134
  else
130
- raise
135
+ raise "PDF does not exist in #{path}"
131
136
  end
132
- elsif message[:role] == 'websearch'
133
- {role: :tool, content: {type: "web_search_preview"} }
137
+ when 'websearch'
138
+ res << {role: :tool, content: {type: "web_search_preview"} }
139
+ when 'previous_response_id'
140
+ res = []
134
141
  else
135
- message
142
+ res << message
136
143
  end
137
- end.flatten
144
+ end
145
+
146
+ res
138
147
  end
139
148
 
140
149
  def self.process_format(format)
@@ -190,6 +199,7 @@ module LLM
190
199
 
191
200
  messages = LLM.chat(question)
192
201
  options = options.merge LLM.options messages
202
+
193
203
 
194
204
  client, url, key, model, log_errors, return_messages, format, websearch, previous_response_id, tools, = IndiferentHash.process_options options,
195
205
  :client, :url, :key, :model, :log_errors, :return_messages, :format, :websearch, :previous_response_id, :tools,
@@ -244,6 +254,7 @@ module LLM
244
254
  Log.medium "Tools: #{Log.fingerprint tools.keys}}" if tools
245
255
 
246
256
  messages = self.process_input messages
257
+
247
258
  input = []
248
259
  messages.each do |message|
249
260
  parameters[:tools] ||= []
@@ -0,0 +1,195 @@
1
+ require 'scout/annotation'
2
+ module Chat
3
+ extend Annotation
4
+
5
+ def message(role, content)
6
+ self.append({role: role.to_s, content: content})
7
+ end
8
+
9
+ def user(content)
10
+ message(:user, content)
11
+ end
12
+
13
+ def system(content)
14
+ message(:system, content)
15
+ end
16
+
17
+ def assistant(content)
18
+ message(:assistant, content)
19
+ end
20
+
21
+ def import(file)
22
+ message(:import, file)
23
+ end
24
+
25
+ def import_last(file)
26
+ message(:last, file)
27
+ end
28
+
29
+ def file(file)
30
+ message(:file, file)
31
+ end
32
+
33
+ def directory(directory)
34
+ message(:directory, directory)
35
+ end
36
+
37
+ def continue(file)
38
+ message(:continue, file)
39
+ end
40
+
41
+ def format(format)
42
+ message(:format, format)
43
+ end
44
+
45
+ def tool(*parts)
46
+ content = parts * "\n"
47
+ message(:tool, content)
48
+ end
49
+
50
+ def task(workflow, task_name, inputs = {})
51
+ input_str = IndiferentHash.print_options inputs
52
+ content = [workflow, task_name, input_str]*" "
53
+ message(:task, content)
54
+ end
55
+
56
+ def inline_task(workflow, task_name, inputs = {})
57
+ input_str = IndiferentHash.print_options inputs
58
+ content = [workflow, task_name, input_str]*" "
59
+ message(:inline_task, content)
60
+ end
61
+
62
+ def job(step)
63
+ message(:job, step.path)
64
+ end
65
+
66
+ def inline_job(step)
67
+ message(:inline_job, step.path)
68
+ end
69
+
70
+
71
+ def association(name, path, options = {})
72
+ options_str = IndiferentHash.print_options options
73
+ content = [name, path, options_str]*" "
74
+ message(:association, name)
75
+ end
76
+
77
+ def tag(content, name=nil, tag=:file, role=:user)
78
+ self.message role, LLM.tag(tag, content, name)
79
+ end
80
+
81
+
82
+ def ask(...)
83
+ LLM.ask(LLM.chat(self), ...)
84
+ end
85
+
86
+ def chat(...)
87
+ response = ask(...)
88
+ if Array === response
89
+ current_chat.concat(response)
90
+ final(response)
91
+ else
92
+ current_chat.push({role: :assistant, content: response})
93
+ response
94
+ end
95
+ end
96
+
97
+ def json(...)
98
+ self.format :json
99
+ output = ask(...)
100
+ obj = JSON.parse output
101
+ if (Hash === obj) and obj.keys == ['content']
102
+ obj['content']
103
+ else
104
+ obj
105
+ end
106
+ end
107
+
108
+ def json_format(format, ...)
109
+ self.format format
110
+ output = ask(...)
111
+ obj = JSON.parse output
112
+ if (Hash === obj) and obj.keys == ['content']
113
+ obj['content']
114
+ else
115
+ obj
116
+ end
117
+ end
118
+
119
+ def branch
120
+ self.annotate self.dup
121
+ end
122
+
123
+ def option(name, value)
124
+ self.message 'option', [name, value] * " "
125
+ end
126
+
127
+ def endpoint(value)
128
+ option :endpoint, value
129
+ end
130
+
131
+ def model(value)
132
+ option :model, value
133
+ end
134
+
135
+ def image(file)
136
+ self.message :image, file
137
+ end
138
+
139
+ # Reporting
140
+
141
+ def print
142
+ LLM.print LLM.chat(self)
143
+ end
144
+
145
+ def final
146
+ LLM.purge(self).last
147
+ end
148
+
149
+ def purge
150
+ Chat.setup(LLM.purge(self))
151
+ end
152
+
153
+ def shed
154
+ self.annotate [final]
155
+ end
156
+
157
+ def answer
158
+ final[:content]
159
+ end
160
+
161
+ # Write and save
162
+
163
+ def save(path, force = true)
164
+ path = path.to_s if Symbol === path
165
+ if not (Open.exists?(path) || Path === path || Path.located?(path))
166
+ path = Scout.chats.find[path]
167
+ end
168
+ return if Open.exists?(path) && ! force
169
+ Open.write path, LLM.print(self)
170
+ end
171
+
172
+ def write(path, force = true)
173
+ path = path.to_s if Symbol === path
174
+ if not (Open.exists?(path) || Path === path || Path.located?(path))
175
+ path = Scout.chats.find[path]
176
+ end
177
+ return if Open.exists?(path) && ! force
178
+ Open.write path, self.print
179
+ end
180
+
181
+ def write_answer(path, force = true)
182
+ path = path.to_s if Symbol === path
183
+ if not (Open.exists?(path) || Path === path || Path.located?(path))
184
+ path = Scout.chats.find[path]
185
+ end
186
+ return if Open.exists?(path) && ! force
187
+ Open.write path, self.answer
188
+ end
189
+
190
+ # Image
191
+ def create_image(file, ...)
192
+ base64_image = LLM.image(LLM.chat(self), ...)
193
+ Open.write(file, Base64.decode(file_content), mode: 'wb')
194
+ end
195
+ end
@@ -0,0 +1,139 @@
1
+ module Chat
2
+ def self.parse(text, role = nil)
3
+ default_role = "user"
4
+
5
+ messages = []
6
+ current_role = role || default_role
7
+ current_content = ""
8
+ in_protected_block = false
9
+ protected_block_type = nil
10
+ protected_stack = []
11
+
12
+ role = default_role if role.nil?
13
+
14
+ file_lines = text.split("\n")
15
+
16
+ file_lines.each do |line|
17
+ stripped = line.strip
18
+
19
+ # Detect protected blocks
20
+ if stripped.start_with?("```")
21
+ if in_protected_block
22
+ in_protected_block = false
23
+ protected_block_type = nil
24
+ current_content << "\n" << line unless line.strip.empty?
25
+ else
26
+ in_protected_block = true
27
+ protected_block_type = :square
28
+ current_content << "\n" << line unless line.strip.empty?
29
+ end
30
+ next
31
+ elsif stripped.end_with?("]]") && in_protected_block && protected_block_type == :square
32
+ in_protected_block = false
33
+ protected_block_type = nil
34
+ line = line.sub("]]", "")
35
+ current_content << "\n" << line unless line.strip.empty?
36
+ next
37
+ elsif stripped.start_with?("[[")
38
+ in_protected_block = true
39
+ protected_block_type = :square
40
+ line = line.sub("[[", "")
41
+ current_content << "\n" << line unless line.strip.empty?
42
+ next
43
+ elsif stripped.end_with?("]]") && in_protected_block && protected_block_type == :square
44
+ in_protected_block = false
45
+ protected_block_type = nil
46
+ line = line.sub("]]", "")
47
+ current_content << "\n" << line unless line.strip.empty?
48
+ next
49
+ elsif stripped.match(/^.*:-- .* {{{/)
50
+ in_protected_block = true
51
+ protected_block_type = :square
52
+ line = line.sub(/^.*:-- (.*) {{{.*/, '<cmd_output cmd="\1">')
53
+ current_content << "\n" << line unless line.strip.empty?
54
+ next
55
+ elsif stripped.match(/^.*:--.* }}}/) && in_protected_block && protected_block_type == :square
56
+ in_protected_block = false
57
+ protected_block_type = nil
58
+ line = line.sub(/^.*:-- .* }}}.*/, "</cmd_output>")
59
+ current_content << "\n" << line unless line.strip.empty?
60
+ next
61
+ elsif in_protected_block
62
+
63
+ if protected_block_type == :xml
64
+ if stripped =~ %r{</(\w+)>}
65
+ closing_tag = $1
66
+ if protected_stack.last == closing_tag
67
+ protected_stack.pop
68
+ end
69
+ if protected_stack.empty?
70
+ in_protected_block = false
71
+ protected_block_type = nil
72
+ end
73
+ end
74
+ end
75
+ current_content << "\n" << line
76
+ next
77
+ end
78
+
79
+ # XML-style tag handling (protected content)
80
+ if stripped =~ /^<(\w+)(\s+[^>]*)?>/
81
+ tag = $1
82
+ protected_stack.push(tag)
83
+ in_protected_block = true
84
+ protected_block_type = :xml
85
+ end
86
+
87
+ # Match a new message header
88
+ if line =~ /^([a-z0-9_]+):(.*)$/
89
+ role = $1
90
+ inline_content = $2.strip
91
+
92
+ current_content = current_content.strip if current_content
93
+ # Save current message if any
94
+ messages << { role: current_role, content: current_content }
95
+
96
+ if inline_content.empty?
97
+ # Block message
98
+ current_role = role
99
+ current_content = ""
100
+ else
101
+ # Inline message + next block is default role
102
+ messages << { role: role, content: inline_content }
103
+ current_role = 'user' if role == 'previous_response_id'
104
+ current_content = ""
105
+ end
106
+ else
107
+ if current_content.nil?
108
+ current_content = line
109
+ else
110
+ current_content += "\n" + line
111
+ end
112
+ end
113
+ end
114
+
115
+ # Final message
116
+ messages << { role: current_role || default_role, content: current_content.strip }
117
+
118
+ messages
119
+ end
120
+
121
+ def self.print(chat)
122
+ return chat if String === chat
123
+ "\n" + chat.collect do |message|
124
+ IndiferentHash.setup message
125
+ case message[:content]
126
+ when Hash, Array
127
+ message[:role].to_s + ":\n\n" + message[:content].to_json
128
+ when nil, ''
129
+ message[:role].to_s + ":"
130
+ else
131
+ if %w(option previous_response_id function_call function_call_output).include? message[:role].to_s
132
+ message[:role].to_s + ": " + message[:content].to_s
133
+ else
134
+ message[:role].to_s + ":\n\n" + message[:content].to_s
135
+ end
136
+ end
137
+ end * "\n\n"
138
+ end
139
+ end
@@ -0,0 +1,29 @@
1
+ module Chat
2
+ def self.clear(messages, role = 'clear')
3
+ new = []
4
+
5
+ messages.reverse.each do |message|
6
+ if message[:role].to_s == role
7
+ break
8
+ else
9
+ new << message
10
+ end
11
+ end
12
+
13
+ new.reverse
14
+ end
15
+
16
+ def self.clean(messages, role = 'skip')
17
+ messages.reject do |message|
18
+ ((String === message[:content]) && message[:content].empty?) ||
19
+ message[:role] == role
20
+ end
21
+ end
22
+
23
+ def self.purge(chat)
24
+ chat.reject do |msg|
25
+ IndiferentHash.setup msg
26
+ msg[:role].to_s == 'previous_response_id'
27
+ end
28
+ end
29
+ end