scout-ai 1.0.0 → 1.0.1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +80 -15
  3. data/README.md +296 -0
  4. data/Rakefile +2 -0
  5. data/VERSION +1 -1
  6. data/doc/Agent.md +279 -0
  7. data/doc/Chat.md +258 -0
  8. data/doc/LLM.md +446 -0
  9. data/doc/Model.md +513 -0
  10. data/doc/RAG.md +129 -0
  11. data/lib/scout/llm/agent/chat.rb +51 -1
  12. data/lib/scout/llm/agent/delegate.rb +39 -0
  13. data/lib/scout/llm/agent/iterate.rb +44 -0
  14. data/lib/scout/llm/agent.rb +42 -21
  15. data/lib/scout/llm/ask.rb +38 -6
  16. data/lib/scout/llm/backends/anthropic.rb +147 -0
  17. data/lib/scout/llm/backends/bedrock.rb +1 -1
  18. data/lib/scout/llm/backends/ollama.rb +23 -29
  19. data/lib/scout/llm/backends/openai.rb +34 -40
  20. data/lib/scout/llm/backends/responses.rb +158 -110
  21. data/lib/scout/llm/chat.rb +250 -94
  22. data/lib/scout/llm/embed.rb +4 -4
  23. data/lib/scout/llm/mcp.rb +28 -0
  24. data/lib/scout/llm/parse.rb +1 -0
  25. data/lib/scout/llm/rag.rb +9 -0
  26. data/lib/scout/llm/tools/call.rb +66 -0
  27. data/lib/scout/llm/tools/knowledge_base.rb +158 -0
  28. data/lib/scout/llm/tools/mcp.rb +59 -0
  29. data/lib/scout/llm/tools/workflow.rb +69 -0
  30. data/lib/scout/llm/tools.rb +58 -143
  31. data/lib/scout-ai.rb +1 -0
  32. data/scout-ai.gemspec +31 -18
  33. data/scout_commands/agent/ask +28 -71
  34. data/scout_commands/documenter +148 -0
  35. data/scout_commands/llm/ask +2 -2
  36. data/scout_commands/llm/server +319 -0
  37. data/share/server/chat.html +138 -0
  38. data/share/server/chat.js +468 -0
  39. data/test/scout/llm/backends/test_anthropic.rb +134 -0
  40. data/test/scout/llm/backends/test_openai.rb +45 -6
  41. data/test/scout/llm/backends/test_responses.rb +124 -0
  42. data/test/scout/llm/test_agent.rb +0 -70
  43. data/test/scout/llm/test_ask.rb +3 -1
  44. data/test/scout/llm/test_chat.rb +43 -1
  45. data/test/scout/llm/test_mcp.rb +29 -0
  46. data/test/scout/llm/tools/test_knowledge_base.rb +22 -0
  47. data/test/scout/llm/tools/test_mcp.rb +11 -0
  48. data/test/scout/llm/tools/test_workflow.rb +39 -0
  49. metadata +56 -17
  50. data/README.rdoc +0 -18
  51. data/python/scout_ai/__pycache__/__init__.cpython-310.pyc +0 -0
  52. data/python/scout_ai/__pycache__/__init__.cpython-311.pyc +0 -0
  53. data/python/scout_ai/__pycache__/huggingface.cpython-310.pyc +0 -0
  54. data/python/scout_ai/__pycache__/huggingface.cpython-311.pyc +0 -0
  55. data/python/scout_ai/__pycache__/util.cpython-310.pyc +0 -0
  56. data/python/scout_ai/__pycache__/util.cpython-311.pyc +0 -0
  57. data/python/scout_ai/atcold/plot_lib.py +0 -141
  58. data/python/scout_ai/atcold/spiral.py +0 -27
  59. data/python/scout_ai/huggingface/train/__pycache__/__init__.cpython-310.pyc +0 -0
  60. data/python/scout_ai/huggingface/train/__pycache__/next_token.cpython-310.pyc +0 -0
  61. data/python/scout_ai/language_model.py +0 -70
  62. /data/{python/scout_ai/atcold/__init__.py → test/scout/llm/tools/test_call.rb} +0 -0
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'scout'
4
+ require 'sinatra'
5
+ require 'json'
6
+ require 'fileutils'
7
+ require 'digest'
8
+
9
+ # Simple Sinatra server to back the offline notebook UI.
10
+ # Provides endpoints to list, load, save and run chat files stored under ./chats
11
+
12
+ set :bind, ENV.fetch('SCOUT_BIND', '127.0.0.1')
13
+ set :port, ENV.fetch('SCOUT_PORT', 4567).to_i
14
+ set :environment, :production
15
+
16
+ CHATS_DIR = File.expand_path('./chats', Dir.pwd)
17
+ FileUtils.mkdir_p(CHATS_DIR)
18
+
19
+ helpers do
20
+ def json!(obj, status = 200)
21
+ content_type :json
22
+ halt status, JSON.generate(obj)
23
+ end
24
+
25
+ def parse_json_body
26
+ body = request.body.read
27
+ return {} if body.nil? || body.strip.empty?
28
+ JSON.parse(body)
29
+ rescue JSON::ParserError
30
+ {}
31
+ end
32
+
33
+ def sanitize_relpath(p)
34
+ return nil if p.nil? || p.to_s.strip.empty?
35
+ s = p.to_s.dup
36
+ s = s.sub(%r{\A/+}, '') # strip leading slashes
37
+ s = s.sub(%r{\Achats/}, '') # allow callers to include the prefix, normalize it away
38
+ return nil if s.include?('..')
39
+ s
40
+ end
41
+
42
+ def safe_path_for(rel)
43
+ rel_s = sanitize_relpath(rel)
44
+ raise Sinatra::NotFound unless rel_s
45
+ full = File.expand_path(File.join(CHATS_DIR, rel_s))
46
+ # ensure the resolved path is inside CHATS_DIR
47
+ unless full.start_with?(CHATS_DIR + File::SEPARATOR) || full == CHATS_DIR
48
+ raise Sinatra::NotFound
49
+ end
50
+ full
51
+ end
52
+
53
+ def etag_for(content)
54
+ Digest::SHA256.hexdigest(content.to_s)
55
+ end
56
+ end
57
+
58
+ # By default we return JSON for the API routes. Some routes will override the content_type.
59
+ before do
60
+ content_type 'application/json'
61
+ end
62
+
63
+ # Serve the web UI from the embedded share unless not available. Override the before filter's JSON content type.
64
+ get '/' do
65
+ begin
66
+ html = nil
67
+ if defined?(Scout) && Scout.respond_to?(:share) && Scout.share.respond_to?(:[]) && Scout.share['server'] && Scout.share['server']['chat.html']
68
+ html = Scout.share['server']['chat.html']
69
+ end
70
+ unless html && !html.to_s.empty?
71
+ # Fallback to the packaged file if the share entry isn't present
72
+ fallback = File.expand_path('../../../share/server/chat.html', __FILE__)
73
+ fallback = File.expand_path('./share/server/chat.html', Dir.pwd) unless File.file?(fallback)
74
+ html = File.read(fallback) if File.file?(fallback)
75
+ end
76
+ content_type 'text/html'
77
+ return html || '<html><body><h1>Chat UI not found</h1></body></html>'
78
+ rescue => e
79
+ content_type 'text/html'
80
+ return "<html><body><h1>Error rendering UI</h1><pre>#{Rack::Utils.escape_html(e.message)}</pre></body></html>"
81
+ end
82
+ end
83
+
84
+ # Serve the client JS (chat.js) from the embedded share if present
85
+ get '/chat.js' do
86
+ begin
87
+ js = nil
88
+ if defined?(Scout) && Scout.respond_to?(:share) && Scout.share.respond_to?(:[]) && Scout.share['server'] && Scout.share['server']['chat.js']
89
+ js = Scout.share['server']['chat.js']
90
+ end
91
+ unless js && !js.to_s.empty?
92
+ fallback = File.expand_path('../../../share/server/chat.js', __FILE__)
93
+ fallback = File.expand_path('./share/server/chat.js', Dir.pwd) unless File.file?(fallback)
94
+ js = File.read(fallback) if File.file?(fallback)
95
+ end
96
+ content_type 'application/javascript'
97
+ return js || 'console.error("chat.js not found");'
98
+ rescue => e
99
+ content_type 'application/javascript'
100
+ return "console.error(#{e.message.inspect});"
101
+ end
102
+ end
103
+
104
+ # list files under chats directory. returns an array of relative paths (no leading 'chats/')
105
+ get '/list' do
106
+ files = []
107
+ Dir.chdir(CHATS_DIR) do
108
+ # include dotmatch in glob so we can filter hidden files/dirs uniformly
109
+ Dir.glob('**/*', File::FNM_DOTMATCH).sort.each do |f|
110
+ next if f == '.' || f == '..'
111
+ # Skip any file or directory that has a path segment starting with '.'
112
+ segments = f.split(File::SEPARATOR)
113
+ next if segments.any? { |seg| seg.start_with?('.') }
114
+ full = File.join(CHATS_DIR, f)
115
+ next if File.directory?(full)
116
+ files << f
117
+ end
118
+ end
119
+ json!(files: files)
120
+ end
121
+
122
+ # load a file's content
123
+ # GET /load?path=relative/path
124
+ get '/load' do
125
+ rel = params['path']
126
+ begin
127
+ full = safe_path_for(rel)
128
+ rescue
129
+ status 404
130
+ return JSON.generate({error: 'not_found'})
131
+ end
132
+ unless File.file?(full)
133
+ status 404
134
+ return JSON.generate({error: 'not_found'})
135
+ end
136
+ content = File.read(full)
137
+ json!(path: sanitize_relpath(rel), content: content, mtime: File.mtime(full).to_i, etag: etag_for(content))
138
+ end
139
+
140
+ # save a file
141
+ # POST /save with JSON { path: 'rel/path', content: '...' }
142
+ post '/save' do
143
+ body = parse_json_body
144
+ rel = body['path']
145
+ content = body['content'] || ''
146
+ begin
147
+ full = safe_path_for(rel)
148
+ rescue
149
+ status 400
150
+ return JSON.generate({error: 'invalid_path'})
151
+ end
152
+ dir = File.dirname(full)
153
+ FileUtils.mkdir_p(dir)
154
+ File.write(full, content)
155
+ json!(path: sanitize_relpath(rel), etag: etag_for(content), mtime: File.mtime(full).to_i)
156
+ end
157
+
158
+ # run a chat: POST /run with JSON { path: 'rel/path', content?: 'unsaved content', convo_options?: {...}, options?: {...} }
159
+ # Behavior: ensure file is saved, then run LLM chat flow and append results to the conversation file and return new content.
160
+ post '/run' do
161
+ body = parse_json_body
162
+ rel = body['path']
163
+ unless rel
164
+ status 400
165
+ return JSON.generate({error: 'missing_path'})
166
+ end
167
+
168
+ begin
169
+ full = safe_path_for(rel)
170
+ rescue
171
+ status 400
172
+ return JSON.generate({error: 'invalid_path'})
173
+ end
174
+
175
+ # If unsaved content provided, write it first
176
+ if body.key?('content')
177
+ FileUtils.mkdir_p(File.dirname(full))
178
+ File.write(full, body['content'] || '')
179
+ end
180
+
181
+ unless File.file?(full)
182
+ status 404
183
+ return JSON.generate({error: 'not_found'})
184
+ end
185
+
186
+ begin
187
+ # Load the conversation file
188
+ file_text = File.read(full)
189
+
190
+ # Attempt to call LLM to run the chat. If LLM is not available, fall back to a simulated reply.
191
+ new_messages = nil
192
+
193
+ llm_defined = defined?(LLM) && (LLM.respond_to?(:chat) || LLM.respond_to?(:ask))
194
+
195
+ if llm_defined
196
+ # If an LLM integration is present, require it to succeed. If it errors, return that error to the client
197
+ begin
198
+ conversation = nil
199
+ begin
200
+ conversation = LLM.chat(file_text)
201
+ rescue => e
202
+ # try passing the filename if the helper expects a path
203
+ begin
204
+ conversation = LLM.chat(full)
205
+ rescue => ee
206
+ # If chat failed entirely, raise to outer rescue so we return an error response
207
+ raise e
208
+ end
209
+ end
210
+
211
+ convo_options = body['convo_options'] || {}
212
+ ask_options = body['options'] || {}
213
+
214
+ begin
215
+ new_messages = LLM.ask(conversation, convo_options.merge(ask_options.merge(return_messages: true)))
216
+ rescue => e
217
+ # propagate the error to the client rather than falling back
218
+ raise e
219
+ end
220
+
221
+ # If the LLM ran but returned no messages, treat that as an error and provide diagnostics
222
+ if new_messages.nil? || (new_messages.respond_to?(:empty?) && new_messages.empty?)
223
+ diag = { error: 'llm_no_messages', message: 'LLM did not return any messages' }
224
+ # include some diagnostic information where safe
225
+ begin
226
+ diag[:conversation_preview] = conversation.respond_to?(:to_s) ? conversation.to_s[0,2000] : conversation.inspect[0,2000]
227
+ rescue
228
+ end
229
+ begin
230
+ diag[:convo_options] = convo_options
231
+ diag[:ask_options] = ask_options
232
+ rescue
233
+ end
234
+ json!(diag, 500)
235
+ end
236
+ rescue => e
237
+ # Return the LLM error to the client with richer diagnostics
238
+ back = (e.backtrace || [])[0,50]
239
+ err = {
240
+ error: 'llm_error',
241
+ error_class: e.class.to_s,
242
+ message: e.message,
243
+ backtrace: back
244
+ }
245
+ # include conversation and request diagnostics when available
246
+ begin
247
+ err[:conversation_preview] = conversation.respond_to?(:to_s) ? conversation.to_s[0,2000] : conversation.inspect[0,2000] if defined?(conversation) && conversation
248
+ rescue
249
+ end
250
+ begin
251
+ err[:convo_options] = convo_options if defined?(convo_options)
252
+ err[:ask_options] = ask_options if defined?(ask_options)
253
+ rescue
254
+ end
255
+ # include any partial new_messages returned (inspect up to a limit)
256
+ begin
257
+ if defined?(new_messages) && new_messages
258
+ nm = new_messages.respond_to?(:to_s) ? new_messages.to_s : new_messages.inspect
259
+ err[:new_messages_preview] = nm[0,2000]
260
+ end
261
+ rescue
262
+ end
263
+
264
+ # Do not fall back to a simulated assistant reply when an LLM error occurred; return the diagnostics instead
265
+ json!(err, 500)
266
+ end
267
+ end
268
+
269
+ new_text = nil
270
+ if new_messages
271
+ # Try to render messages into text. Prefer LLM.print if available.
272
+ printed = nil
273
+ if defined?(LLM) && LLM.respond_to?(:print)
274
+ begin
275
+ printed = LLM.print(new_messages)
276
+ rescue
277
+ printed = nil
278
+ end
279
+ end
280
+ # If we couldn't print, try to stringify
281
+ printed ||= begin
282
+ if new_messages.respond_to?(:to_s)
283
+ new_messages.to_s
284
+ else
285
+ JSON.generate(new_messages)
286
+ end
287
+ end
288
+
289
+ new_text = file_text + "\n" + printed
290
+ else
291
+ # If no LLM is present at all, keep the previous fallback simulated reply behavior
292
+ simulated = "assistant: (simulated reply)\n"
293
+ new_text = file_text + "\n" + simulated
294
+ end
295
+
296
+ # Save the updated conversation back to disk
297
+ File.write(full, new_text)
298
+
299
+ json!(path: sanitize_relpath(rel), content: new_text, etag: etag_for(new_text), mtime: File.mtime(full).to_i)
300
+ rescue => e
301
+ status 500
302
+ back = (e.backtrace || [])[0,50]
303
+ resp = { error: 'run_failed', error_class: e.class.to_s, message: e.message, backtrace: back }
304
+ begin
305
+ resp[:file] = full if defined?(full)
306
+ resp[:path] = sanitize_relpath(rel) if defined?(rel)
307
+ rescue
308
+ end
309
+ json!(resp, 500)
310
+ end
311
+ end
312
+
313
+ # Basic health endpoint
314
+ get '/ping' do
315
+ json!(ok: true, dir: CHATS_DIR)
316
+ end
317
+
318
+ $stderr.puts "Starting scout server on #{settings.bind}:#{settings.port}, chats dir=#{CHATS_DIR}\n"
319
+ Sinatra::Application.run!
@@ -0,0 +1,138 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Scout-AI Notebook Editor — Offline Cells (Server-backed)</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <style>
8
+ :root{--bg:#0f1115;--panel:#151821;--muted:#9aa3b2;--border:#222838;--text:#e6e6e6;--accent:#3b82f6}
9
+ body{font-family:Inter,system-ui,Arial; margin:16px; background:var(--bg); color:var(--text)}
10
+ .wrap{max-width:880px;margin:0 auto}
11
+ header{display:flex;align-items:center;gap:12px;margin-bottom:12px}
12
+ h1{font-size:16px;margin:0}
13
+ .panel{background:var(--panel);padding:12px;border-radius:8px;border:1px solid var(--border)}
14
+ .toolbar{display:flex;gap:8px;align-items:center;margin-bottom:12px}
15
+ .row{display:flex;gap:8px;align-items:center}
16
+ input[type="text"]{flex:1;padding:8px;border-radius:6px;border:1px solid var(--border);background:#0c0f16;color:var(--text)}
17
+ button{padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:#0c0f16;color:var(--text);cursor:pointer}
18
+ button.small{padding:5px 6px;font-size:12px}
19
+ .cells{margin-top:8px;display:flex;flex-direction:column;gap:10px}
20
+ .cell{border:1px solid transparent;border-radius:12px;padding:10px 12px;background:linear-gradient(180deg,#0b0d11, #0b0d11);position:relative;transition:box-shadow .12s, border-color .12s}
21
+ .cell:hover{box-shadow:0 4px 18px rgba(0,0,0,.6);border-color:rgba(255,255,255,0.02)}
22
+ .cell-header{display:flex;gap:8px;align-items:center;margin-bottom:8px}
23
+ .role-badge{padding:6px 8px;border-radius:999px;background:#081018;border:1px solid var(--border);color:var(--muted);font-weight:600;font-size:12px;cursor:pointer}
24
+ .role-badge.user{color:#9fdfff}
25
+ .role-badge.system{color:#c9b6ff}
26
+ .role-badge.assistant{color:#a7f3d0}
27
+ .role-badge.note{color:var(--muted)}
28
+ .cell-controls{margin-left:auto;display:flex;gap:6px;align-items:center;opacity:0;transition:opacity .12s}
29
+ .cell:hover .cell-controls{opacity:1}
30
+ .cell-actions button{background:transparent;border:1px solid transparent;padding:4px 6px;border-radius:6px;color:var(--muted);font-size:12px}
31
+ .cell-text{width:100%;min-height:56px;max-height:300px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#081018;color:var(--text);font-family:ui-monospace,monospace}
32
+ .cell-input{width:100%;padding:8px;border-radius:8px;border:1px solid var(--border);background:#081018;color:var(--text)}
33
+ .muted{color:var(--muted);font-size:13px}
34
+ #log{height:72px; overflow:auto; background:#0b0d11;padding:8px;border-radius:6px;border:1px solid var(--border);color:var(--muted);font-size:13px}
35
+ .file-list{max-height:72px;overflow:auto;margin-top:8px}
36
+ .file-item{padding:4px 6px;border-radius:6px;display:flex;justify-content:space-between;gap:8px}
37
+ .file-item a{color:var(--accent);text-decoration:none}
38
+
39
+ /* loading indicator */
40
+ .loading {display:inline-flex;align-items:center;gap:8px;color:var(--accent);font-weight:600}
41
+ .spinner {width:14px;height:14px;border:2px solid rgba(255,255,255,0.08);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite}
42
+ @keyframes spin{to{transform:rotate(360deg)}}
43
+
44
+ /* modal */
45
+ .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:none;align-items:center;justify-content:center;z-index:999}
46
+ .modal{background:#0b0d11;color:var(--text);border:1px solid var(--border);border-radius:8px;max-width:800px;width:90%;max-height:80%;overflow:auto;padding:16px}
47
+ .modal-header{display:flex;align-items:center;gap:8px;margin-bottom:8px}
48
+ .modal-body{background:#081018;padding:12px;border-radius:6px}
49
+ .modal-close{margin-left:auto}
50
+
51
+ /* assistant message preview styling */
52
+ .message-preview{white-space:pre-wrap;color:var(--text)}
53
+ .assistant-bubble{background:linear-gradient(180deg,#071017,#091623);padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,0.02)}
54
+ .user-bubble{background:transparent;padding:8px}
55
+
56
+ /* compact toolbar */
57
+ .top-helpers{display:flex;gap:12px;align-items:flex-start}
58
+ .top-left{flex:1}
59
+ .top-right{width:280px}
60
+
61
+ @media(max-width:900px){ .wrap{margin:8px} .top-right{display:none} }
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <div class="wrap">
66
+ <header>
67
+ <h1>Scout-AI Notebook Editor</h1>
68
+ <div class="small">Server-backed chat-style editor</div>
69
+ </header>
70
+
71
+ <div class="panel">
72
+ <div class="toolbar">
73
+ <div class="top-helpers" style="width:100%">
74
+ <div class="top-left">
75
+ <div style="display:flex;gap:8px;align-items:center">
76
+ <div style="width:320px;display:flex;flex-direction:column">
77
+ <label class="small">Path</label>
78
+ <input id="path" class="path-input" placeholder="chats/doc/new_chat" list="files_datalist" />
79
+ </div>
80
+
81
+ <div class="row">
82
+ <button id="newFileBtn">New</button>
83
+ <button id="loadBtn">Load</button>
84
+ <button id="saveBtn">Save</button>
85
+ <button id="runBtn">Run</button>
86
+ <button id="exportBtn">Export Text</button>
87
+ </div>
88
+
89
+ <div style="flex:1"></div>
90
+ </div>
91
+
92
+ <div style="display:flex;gap:8px;margin-top:8px;align-items:flex-start">
93
+ <div style="display:flex;gap:8px">
94
+ <button id="addUser">+ user</button>
95
+ <button id="addSystem">+ system</button>
96
+ <button id="addAssistant">+ assistant</button>
97
+ <button id="addOption">+ option</button>
98
+ <button id="clearBtn" class="small">Clear</button>
99
+ </div>
100
+ <div style="margin-left:12px" class="muted">Cells: <span id="cellCount">0</span></div>
101
+ <div style="width:12px"></div>
102
+ <div id="loadingIndicator" style="display:none" class="loading"><div class="spinner"></div><div>Running…</div></div>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="top-right">
107
+ <div class="small">Workspace Files</div>
108
+ <div id="files" class="file-list"></div>
109
+ <hr style="border:0;border-top:1px solid var(--border);margin:8px 0">
110
+ <div class="small">Log</div>
111
+ <div id="log"></div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <div id="cells" class="cells"></div>
117
+
118
+ <div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end">
119
+ <button id="runBtnBottom">Run</button>
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Markdown preview modal -->
125
+ <div id="mdModal" class="modal-overlay" aria-hidden="true">
126
+ <div class="modal" role="dialog" aria-modal="true">
127
+ <div class="modal-header">
128
+ <strong id="mdTitle">Preview</strong>
129
+ <button id="mdCopy" class="small">Copy</button>
130
+ <button id="mdClose" class="modal-close">Close</button>
131
+ </div>
132
+ <div id="mdBody" class="modal-body"></div>
133
+ </div>
134
+ </div>
135
+
136
+ <script src="/chat.js" defer></script>
137
+ </body>
138
+ </html>