samagotchi 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -70,9 +70,8 @@ module Samagotchi
|
|
|
70
70
|
|
|
71
71
|
# @param bridge_wait_timeout [Float] bounded seconds to wait for a
|
|
72
72
|
# freshly-spawned worker's bridge before answering POST /api/sessions.
|
|
73
|
-
# @param
|
|
74
|
-
#
|
|
75
|
-
# overrides it for one page load
|
|
73
|
+
# @param view ["turn", "stage", "chat"] the page's view of a turn
|
|
74
|
+
# (web.view); ?view=turn|stage|chat overrides it for one page load
|
|
76
75
|
# @param annotate_presets [String, Array] the quick replies next to
|
|
77
76
|
# Annotate (web.annotate_presets, "|"-separated); "" shows none. The
|
|
78
77
|
# page parses them (annotate_presets.js).
|
|
@@ -82,11 +81,15 @@ module Samagotchi
|
|
|
82
81
|
# (built from the config on first use)
|
|
83
82
|
# @param models_wait_timeout [Float] bounded seconds GET /api/models
|
|
84
83
|
# waits for the hosts' lists
|
|
84
|
+
# @param lan [Hash, nil] LAN mode (web.host: lan): { ip:, token: },
|
|
85
|
+
# the address the server also listens on and its access token (a
|
|
86
|
+
# String, or a Web::Token::Source that follows the file); nil:
|
|
87
|
+
# loopback only, no token
|
|
85
88
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
86
|
-
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false,
|
|
89
|
+
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: "turn", hub: nil,
|
|
87
90
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
88
91
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
89
|
-
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT)
|
|
92
|
+
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
|
|
90
93
|
@manager = manager || SessionManager
|
|
91
94
|
@registry = registry
|
|
92
95
|
@models_wait_timeout = models_wait_timeout
|
|
@@ -97,12 +100,15 @@ module Samagotchi
|
|
|
97
100
|
@public_dir = public_dir || File.expand_path("public", __dir__)
|
|
98
101
|
@bridge_wait_timeout = bridge_wait_timeout
|
|
99
102
|
@markdown_renderer = MarkdownRenderer.new(enabled: markdown)
|
|
100
|
-
@
|
|
103
|
+
@view = view
|
|
101
104
|
@annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
|
|
102
105
|
@hub = hub
|
|
103
106
|
@events_heartbeat = events_heartbeat
|
|
104
107
|
@events_queue = events_queue
|
|
105
108
|
@server_running = -> { true }
|
|
109
|
+
@lan = lan
|
|
110
|
+
@refused_mutex = Mutex.new
|
|
111
|
+
@refused_logged = {}
|
|
106
112
|
end
|
|
107
113
|
|
|
108
114
|
def call(env)
|
|
@@ -116,7 +122,12 @@ module Samagotchi
|
|
|
116
122
|
|
|
117
123
|
def route(env)
|
|
118
124
|
req = Rack::Request.new(env)
|
|
119
|
-
return forbidden unless
|
|
125
|
+
return forbidden unless local_host_header?(env)
|
|
126
|
+
return cross_origin if cross_site?(env)
|
|
127
|
+
if @lan && !LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s)
|
|
128
|
+
denied = token_gate(req)
|
|
129
|
+
return denied if denied
|
|
130
|
+
end
|
|
120
131
|
|
|
121
132
|
case [req.request_method, req.path_info]
|
|
122
133
|
when ["GET", "/"], ["GET", "/index.html"]
|
|
@@ -199,15 +210,140 @@ module Samagotchi
|
|
|
199
210
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
200
211
|
end
|
|
201
212
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
LOOPBACK_NAMES = %w[127.0.0.1 [::1] localhost].freeze
|
|
214
|
+
LOOPBACK_PEERS = %w[127.0.0.1 ::1 ::ffff:127.0.0.1].freeze
|
|
215
|
+
|
|
216
|
+
# Only 127.0.0.1 / [::1] / localhost (any port), and in LAN mode the
|
|
217
|
+
# LAN address, are answered. The socket is bound to those, but a Host
|
|
218
|
+
# check stops DNS-rebinding pages. It reads the raw Host header:
|
|
219
|
+
# Rack's req.host trusts X-Forwarded-Host, which such a page could
|
|
220
|
+
# set. A request with no Host (HTTP/1.0) is answered only for a
|
|
221
|
+
# loopback peer.
|
|
222
|
+
def local_host_header?(env)
|
|
223
|
+
host = env["HTTP_HOST"].to_s
|
|
224
|
+
return LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s) if host.empty?
|
|
225
|
+
|
|
226
|
+
name = host.downcase.sub(/:\d*\z/, "")
|
|
227
|
+
LOOPBACK_NAMES.include?(name) || (!@lan.nil? && name == @lan[:ip])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
TOKEN_COOKIE = "chi_token"
|
|
231
|
+
# 400 days, the longest a browser keeps a cookie (Chrome's cap). No
|
|
232
|
+
# Secure: the LAN page is plain http. Lax, not Strict: a token link
|
|
233
|
+
# opened from another site (a web messenger) makes its 303 chain
|
|
234
|
+
# cross-site, and a Strict cookie wouldn't ride on the redirected GET;
|
|
235
|
+
# the cross-site check already refuses what Strict would add.
|
|
236
|
+
TOKEN_COOKIE_ATTRIBUTES = "HttpOnly; SameSite=Lax; Path=/; Max-Age=34560000"
|
|
237
|
+
REFUSED_LOG_INTERVAL = 60.0
|
|
238
|
+
|
|
239
|
+
# LAN mode, a peer that isn't this machine (the peer is the socket's
|
|
240
|
+
# address, REMOTE_ADDR: never X-Forwarded-For or req.ip, which trust
|
|
241
|
+
# headers a phone could send): every route needs the access token, as
|
|
242
|
+
# the chi_token cookie or an Authorization: Bearer header. The page
|
|
243
|
+
# with ?token= trades it for the cookie and a 303 to itself without
|
|
244
|
+
# it, so the token doesn't stay in the phone's history.
|
|
245
|
+
# @return [Array, nil] the response that refuses it, or nil to go on
|
|
246
|
+
def token_gate(req)
|
|
247
|
+
token = current_token
|
|
248
|
+
if req.get? && ["/", "/index.html"].include?(req.path_info) && req.params.key?("token")
|
|
249
|
+
return token_redirect(req, token) if token_match?(req.params["token"], token)
|
|
250
|
+
else
|
|
251
|
+
auth = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
|
|
252
|
+
return nil if token_match?(auth, token) || token_match?(req.cookies[TOKEN_COOKIE], token)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
unauthorized(req)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def current_token
|
|
259
|
+
token = @lan[:token]
|
|
260
|
+
token.respond_to?(:current) ? token.current : token
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def token_match?(given, token)
|
|
264
|
+
return false if given.nil? || given.empty? || token.nil? || token.empty?
|
|
265
|
+
|
|
266
|
+
Rack::Utils.secure_compare(given, token)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The same page (and its other parameters, as they were written), the
|
|
270
|
+
# token traded for the cookie.
|
|
271
|
+
def token_redirect(req, token)
|
|
272
|
+
query = req.query_string.split("&").reject { |pair| pair.split("=", 2).first == "token" }.join("&")
|
|
273
|
+
location = query.empty? ? req.path_info : "#{req.path_info}?#{query}"
|
|
274
|
+
[303, { "Location" => location, "Set-Cookie" => "#{TOKEN_COOKIE}=#{token}; #{TOKEN_COOKIE_ATTRIBUTES}",
|
|
275
|
+
"Cache-Control" => "no-store", "Content-Length" => "0" }, []]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def unauthorized(req)
|
|
279
|
+
log_refused(req.get_header("REMOTE_ADDR").to_s, req.path_info)
|
|
280
|
+
return error_response(401, "unauthorized", "chi web on the LAN needs its access token") if req.path_info.start_with?("/api/")
|
|
281
|
+
|
|
282
|
+
body = UNAUTHORIZED_PAGE
|
|
283
|
+
[401, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" },
|
|
284
|
+
[body]]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# At most once a minute per peer: a phone with an old link retries.
|
|
288
|
+
def log_refused(peer, path)
|
|
289
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
290
|
+
due = @refused_mutex.synchronize do
|
|
291
|
+
@refused_logged.delete_if { |_, at| now - at >= REFUSED_LOG_INTERVAL } if @refused_logged.size > 64
|
|
292
|
+
last = @refused_logged[peer]
|
|
293
|
+
(last.nil? || now - last >= REFUSED_LOG_INTERVAL).tap { |fresh| @refused_logged[peer] = now if fresh }
|
|
294
|
+
end
|
|
295
|
+
Log.warn(:web, "unauthorized", peer: peer, path: path) if due
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# What a phone sees without the token. The field is for a home-screen
|
|
299
|
+
# web app: it has its own cookies, and the QR code opens the browser.
|
|
300
|
+
UNAUTHORIZED_PAGE = <<~HTML
|
|
301
|
+
<!doctype html>
|
|
302
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
303
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
304
|
+
<title>chi web: access token needed</title>
|
|
305
|
+
<style>
|
|
306
|
+
:root { color-scheme: light dark; }
|
|
307
|
+
body { font: 16px/1.5 -apple-system, system-ui, sans-serif; max-width: 30rem; margin: 3rem auto; padding: 0 1rem; }
|
|
308
|
+
input { font: inherit; width: 100%; box-sizing: border-box; padding: .5rem; margin: .5rem 0; }
|
|
309
|
+
button { font: inherit; padding: .4rem 1rem; }
|
|
310
|
+
</style></head>
|
|
311
|
+
<body>
|
|
312
|
+
<h1>chi web</h1>
|
|
313
|
+
<p>This page needs chi web's access token. Open the LAN link chi web printed in its terminal, or scan its QR code.</p>
|
|
314
|
+
<form method="get" action="/">
|
|
315
|
+
<label for="token">Or paste the token:</label>
|
|
316
|
+
<input id="token" name="token" autocomplete="off" autocapitalize="off" spellcheck="false">
|
|
317
|
+
<button type="submit">Open</button>
|
|
318
|
+
</form>
|
|
319
|
+
</body></html>
|
|
320
|
+
HTML
|
|
321
|
+
|
|
322
|
+
# Another website's page in the desktop browser can send "simple"
|
|
323
|
+
# requests here (a text/plain POST needs no preflight) and would start
|
|
324
|
+
# sessions that run commands. Refused: any request but GET/HEAD from
|
|
325
|
+
# another site (Sec-Fetch-Site cross-site/same-site, or an Origin other
|
|
326
|
+
# than exactly this server's: another local port and "null" are
|
|
327
|
+
# foreign), and GETs of /api/* the same way (no blind reads, no SSE
|
|
328
|
+
# subscriptions). The page itself is same-origin; curl, Net::HTTP and
|
|
329
|
+
# chi's own clients send neither header and pass. A link followed from
|
|
330
|
+
# another site still opens the page (a top-level GET).
|
|
331
|
+
def cross_site?(env)
|
|
332
|
+
foreign = %w[cross-site same-site].include?(env["HTTP_SEC_FETCH_SITE"].to_s.downcase)
|
|
333
|
+
origin = env["HTTP_ORIGIN"]
|
|
334
|
+
foreign ||= !origin.nil? && origin.downcase != "http://#{env["HTTP_HOST"].to_s.downcase}"
|
|
335
|
+
return false unless foreign
|
|
336
|
+
|
|
337
|
+
!%w[GET HEAD].include?(env["REQUEST_METHOD"]) || env["PATH_INFO"].to_s.start_with?("/api/")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def cross_origin
|
|
341
|
+
error_response(403, "cross_origin", "requests from other websites are refused")
|
|
207
342
|
end
|
|
208
343
|
|
|
209
344
|
def forbidden
|
|
210
|
-
|
|
345
|
+
names = @lan ? "127.0.0.1, [::1], localhost and #{@lan[:ip]}" : "127.0.0.1, [::1] and localhost"
|
|
346
|
+
error_response(403, "forbidden", "only the host names #{names} are answered")
|
|
211
347
|
end
|
|
212
348
|
|
|
213
349
|
# The page's scope: ?dir=<folder> lists that folder's project (every
|
|
@@ -248,7 +384,7 @@ module Samagotchi
|
|
|
248
384
|
session_to_json(s, status: displayed_status(s, owner: owner), owner: owner, root_cache: roots)
|
|
249
385
|
end
|
|
250
386
|
# Expose total via header for pagination (total unordered count)
|
|
251
|
-
headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store"
|
|
387
|
+
headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store" }
|
|
252
388
|
# Compute total without limit/offset for header
|
|
253
389
|
if limit || offset.positive?
|
|
254
390
|
total = if @state_dir
|
|
@@ -269,7 +405,7 @@ module Samagotchi
|
|
|
269
405
|
all = @hub.snapshot(project_root: root, sort: sort, order: order)
|
|
270
406
|
page = all.drop(offset)
|
|
271
407
|
page = page.first(limit) if limit
|
|
272
|
-
headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store"
|
|
408
|
+
headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store" }
|
|
273
409
|
headers["X-Total-Count"] = all.size.to_s if limit || offset.positive?
|
|
274
410
|
body = JSON.generate(page)
|
|
275
411
|
headers["Content-Length"] = body.bytesize.to_s
|
|
@@ -294,7 +430,7 @@ module Samagotchi
|
|
|
294
430
|
# is chi web, and it knows ?dir (features).
|
|
295
431
|
def handle_info
|
|
296
432
|
json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, cwd: Dir.pwd,
|
|
297
|
-
features: ["dir"] })
|
|
433
|
+
features: ["dir"], lan: @lan && @lan[:ip] })
|
|
298
434
|
end
|
|
299
435
|
|
|
300
436
|
# The models a new session can start on, spelled as chi spells them
|
|
@@ -415,9 +551,9 @@ module Samagotchi
|
|
|
415
551
|
folder[:title] = preview unless preview.empty?
|
|
416
552
|
begin
|
|
417
553
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
418
|
-
rescue
|
|
419
|
-
|
|
420
|
-
|
|
554
|
+
rescue ModelProfile::MissingModel => e
|
|
555
|
+
# No model configured, or one qualified with an unknown host.
|
|
556
|
+
return error_response(400, "invalid_model", e.message)
|
|
421
557
|
end
|
|
422
558
|
# The worker's Bridge is the single live transport: wait (bounded) for
|
|
423
559
|
# it so the client can attach without client-side polling.
|
|
@@ -480,7 +616,8 @@ module Samagotchi
|
|
|
480
616
|
json_response(200, {
|
|
481
617
|
session: session_json,
|
|
482
618
|
history: read_history(id),
|
|
483
|
-
messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1"
|
|
619
|
+
messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
|
|
620
|
+
cwd: session.respond_to?(:working_directory) ? session.working_directory : nil),
|
|
484
621
|
failed_turn: failed_turn_for(session, raw_messages),
|
|
485
622
|
current_turn: current_turn,
|
|
486
623
|
queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
|
|
@@ -889,8 +1026,7 @@ module Samagotchi
|
|
|
889
1026
|
"Content-Type" => "text/event-stream",
|
|
890
1027
|
"Cache-Control" => "no-cache",
|
|
891
1028
|
"Connection" => "keep-alive",
|
|
892
|
-
"X-Accel-Buffering" => "no"
|
|
893
|
-
"Access-Control-Allow-Origin" => "*"
|
|
1029
|
+
"X-Accel-Buffering" => "no"
|
|
894
1030
|
}
|
|
895
1031
|
|
|
896
1032
|
# Handlers that buffer enumerable bodies before responding (rackup's
|
|
@@ -924,8 +1060,7 @@ module Samagotchi
|
|
|
924
1060
|
"Content-Type" => "text/event-stream",
|
|
925
1061
|
"Cache-Control" => "no-cache",
|
|
926
1062
|
"Connection" => "keep-alive",
|
|
927
|
-
"X-Accel-Buffering" => "no"
|
|
928
|
-
"Access-Control-Allow-Origin" => "*"
|
|
1063
|
+
"X-Accel-Buffering" => "no"
|
|
929
1064
|
}
|
|
930
1065
|
body = EventsBody.new(hub: @hub, project_root: root, heartbeat: @events_heartbeat, capacity: @events_queue,
|
|
931
1066
|
server_running: @server_running)
|
|
@@ -1159,7 +1294,9 @@ module Samagotchi
|
|
|
1159
1294
|
# assistant message also carries what it did (MessageParts: thinking,
|
|
1160
1295
|
# tool calls with params and output), and a message with nothing to
|
|
1161
1296
|
# show but that (a text-less step) is kept with empty content
|
|
1162
|
-
|
|
1297
|
+
# @param cwd [String, nil] the session's working directory (the parts'
|
|
1298
|
+
# tool titles are relative to it)
|
|
1299
|
+
def messages_for_display(msgs, parts: false, cwd: nil)
|
|
1163
1300
|
filtered = []
|
|
1164
1301
|
list = Array(msgs)
|
|
1165
1302
|
list.each_with_index do |m, index|
|
|
@@ -1179,7 +1316,7 @@ module Samagotchi
|
|
|
1179
1316
|
next if role == "tool_response"
|
|
1180
1317
|
|
|
1181
1318
|
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
1182
|
-
did = parts && %w[model assistant].include?(role) ? message_parts(list, index) : nil
|
|
1319
|
+
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd) : nil
|
|
1183
1320
|
next if stripped.empty? && did.nil?
|
|
1184
1321
|
|
|
1185
1322
|
norm_role = role == "model" ? "assistant" : role
|
|
@@ -1222,9 +1359,9 @@ module Samagotchi
|
|
|
1222
1359
|
|
|
1223
1360
|
# The parts of the assistant message at +index+, with the tool_response
|
|
1224
1361
|
# messages right after it.
|
|
1225
|
-
def message_parts(list, index)
|
|
1362
|
+
def message_parts(list, index, cwd = nil)
|
|
1226
1363
|
responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
|
|
1227
|
-
MessageParts.for_message(list[index], responses)
|
|
1364
|
+
MessageParts.for_message(list[index], responses, cwd: cwd)
|
|
1228
1365
|
end
|
|
1229
1366
|
|
|
1230
1367
|
# The last message messages_for_display shows as an answer, as a list of
|
|
@@ -1280,7 +1417,7 @@ module Samagotchi
|
|
|
1280
1417
|
# nothing here: the list call answers 400 and the page says so.
|
|
1281
1418
|
def index_data_attributes(req)
|
|
1282
1419
|
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
|
|
1283
|
-
attrs["
|
|
1420
|
+
attrs["view"] = view_name(req.params["view"])
|
|
1284
1421
|
# Always there: an empty one means no presets, not the default.
|
|
1285
1422
|
attrs["annotate-presets"] = @annotate_presets
|
|
1286
1423
|
dir, = scope_dir(req.params["dir"])
|
|
@@ -1301,19 +1438,17 @@ module Samagotchi
|
|
|
1301
1438
|
attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
|
|
1302
1439
|
end
|
|
1303
1440
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
else @turn_view
|
|
1311
|
-
end
|
|
1441
|
+
VIEWS = Config::BY_KEY["web.view"].enum_values
|
|
1442
|
+
|
|
1443
|
+
# The view for this page load: ?view=turn|stage|chat wins, anything
|
|
1444
|
+
# else leaves the config's choice (web.view).
|
|
1445
|
+
def view_name(param)
|
|
1446
|
+
VIEWS.include?(param) ? param : @view
|
|
1312
1447
|
end
|
|
1313
1448
|
|
|
1314
1449
|
def json_response(status, payload)
|
|
1315
1450
|
body = JSON.generate(payload)
|
|
1316
|
-
[status, { "Content-Type" => "application/json; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store"
|
|
1451
|
+
[status, { "Content-Type" => "application/json; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" }, [body]]
|
|
1317
1452
|
end
|
|
1318
1453
|
|
|
1319
1454
|
# The request body, "" when there is none: WEBrick (through rackup)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Web
|
|
8
|
+
# web.host beyond loopback: `lan` (this machine's private IPv4 address)
|
|
9
|
+
# or one of its IPv4 addresses. chi web then listens on 127.0.0.1 and
|
|
10
|
+
# that address; the local tools (a second chi web, chi update) keep
|
|
11
|
+
# talking to 127.0.0.1 (local_host). IPv6 LAN addresses aren't offered.
|
|
12
|
+
module Lan
|
|
13
|
+
LOOPBACK_NAMES = %w[127.0.0.1 ::1 localhost].freeze
|
|
14
|
+
# Interfaces `lan` never picks: VPN tunnels, bridges, VMs and
|
|
15
|
+
# containers, Apple's low-latency link.
|
|
16
|
+
SKIPPED = /\A(utun|bridge|docker|vboxnet|vmnet|llw)/
|
|
17
|
+
PRIVATE = [IPAddr.new("10.0.0.0/8"), IPAddr.new("172.16.0.0/12"), IPAddr.new("192.168.0.0/16")].freeze
|
|
18
|
+
|
|
19
|
+
Address = Struct.new(:ip, :interface, keyword_init: true) do
|
|
20
|
+
def to_s = "#{ip} (#{interface})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The address chi web adds, the other private ones `lan` could have
|
|
24
|
+
# picked (said in the startup lines), and whether it isn't a private
|
|
25
|
+
# one (an explicit Tailscale or public address: a warning).
|
|
26
|
+
Choice = Struct.new(:ip, :interface, :others, :public, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
class Error < StandardError; end
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Does this web.host ask for LAN access? `lan`, or an IPv4 address
|
|
33
|
+
# that isn't loopback or 0.0.0.0 (never bound: every interface).
|
|
34
|
+
def wanted?(setting)
|
|
35
|
+
setting = setting.to_s.strip
|
|
36
|
+
return true if setting == "lan"
|
|
37
|
+
|
|
38
|
+
ip = ipv4(setting)
|
|
39
|
+
!ip.nil? && !ip.loopback? && ip != IPAddr.new("0.0.0.0")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Where this machine reaches its own chi web: the loopback address
|
|
43
|
+
# for `lan` or a LAN address, ::1 and localhost as they are, and
|
|
44
|
+
# 127.0.0.1 for anything else (chi web binds that instead).
|
|
45
|
+
def local_host(setting)
|
|
46
|
+
setting = setting.to_s.strip
|
|
47
|
+
%w[::1 localhost].include?(setting) ? setting : "127.0.0.1"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param ifaddrs [Array<Socket::Ifaddr>] this machine's interfaces
|
|
51
|
+
# @return [Choice]
|
|
52
|
+
# @raise [Error] `lan` with no private address, or an address that
|
|
53
|
+
# isn't this machine's
|
|
54
|
+
def choose(setting, ifaddrs: Socket.getifaddrs)
|
|
55
|
+
setting = setting.to_s.strip
|
|
56
|
+
mine = addresses(ifaddrs)
|
|
57
|
+
if setting == "lan"
|
|
58
|
+
candidates = mine.select { |a| private?(a.ip) && !a.interface.match?(SKIPPED) }
|
|
59
|
+
raise Error, "web.host is lan, but this machine has no private IPv4 address on the network (Wi-Fi off?)" if candidates.empty?
|
|
60
|
+
|
|
61
|
+
first, *others = candidates
|
|
62
|
+
return Choice.new(ip: first.ip, interface: first.interface, others: others, public: false)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
found = mine.find { |a| a.ip == setting }
|
|
66
|
+
raise Error, "web.host is #{setting}, which isn't an address of this machine" unless found
|
|
67
|
+
|
|
68
|
+
others = mine.select { |a| a.ip != found.ip && private?(a.ip) && !a.interface.match?(SKIPPED) }
|
|
69
|
+
Choice.new(ip: found.ip, interface: found.interface, others: others, public: !private?(found.ip))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# This machine's IPv4 addresses on interfaces that are up, loopback
|
|
73
|
+
# left out, in the system's order.
|
|
74
|
+
def addresses(ifaddrs)
|
|
75
|
+
ifaddrs.filter_map do |ifa|
|
|
76
|
+
addr = ifa.addr
|
|
77
|
+
next unless addr&.ipv4?
|
|
78
|
+
next if ifa.flags.nobits?(Socket::IFF_UP) || ifa.flags.anybits?(Socket::IFF_LOOPBACK)
|
|
79
|
+
|
|
80
|
+
Address.new(ip: addr.ip_address, interface: ifa.name)
|
|
81
|
+
end.uniq(&:ip)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def private?(ip)
|
|
85
|
+
addr = ipv4(ip)
|
|
86
|
+
!addr.nil? && PRIVATE.any? { |net| net.include?(addr) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ipv4(text)
|
|
90
|
+
return nil unless text.to_s.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/)
|
|
91
|
+
|
|
92
|
+
addr = IPAddr.new(text)
|
|
93
|
+
addr.ipv4? ? addr : nil
|
|
94
|
+
rescue IPAddr::Error
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -55,15 +55,17 @@ module Samagotchi
|
|
|
55
55
|
# @param message [Hash] a saved model message (symbol or string keys)
|
|
56
56
|
# @param responses [Array<Hash>] the tool_response messages after it
|
|
57
57
|
# @param registry [Tools::Registry] the session's tools
|
|
58
|
-
# @
|
|
59
|
-
#
|
|
60
|
-
|
|
58
|
+
# @param cwd [String, nil] the session's working directory (a file
|
|
59
|
+
# tool's title is relative to it)
|
|
60
|
+
# @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
|
|
61
|
+
# output:, output_truncated:, images: }] } with only what it found; nil for nothing
|
|
62
|
+
def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil)
|
|
61
63
|
content = field(message, :content).to_s
|
|
62
64
|
calls = field(message, :tool_calls)
|
|
63
65
|
tools = if calls.is_a?(Array) && !calls.empty?
|
|
64
|
-
native_tools(calls, responses, registry)
|
|
66
|
+
native_tools(calls, responses, registry, cwd)
|
|
65
67
|
else
|
|
66
|
-
markup_tools(content, responses, registry)
|
|
68
|
+
markup_tools(content, responses, registry, cwd)
|
|
67
69
|
end
|
|
68
70
|
parts = {}
|
|
69
71
|
thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
|
|
@@ -83,7 +85,7 @@ module Samagotchi
|
|
|
83
85
|
blocks.map(&:strip).reject(&:empty?).join("\n\n")
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
def markup_tools(content, responses, registry)
|
|
88
|
+
def markup_tools(content, responses, registry, cwd = nil)
|
|
87
89
|
calls = []
|
|
88
90
|
calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
|
|
89
91
|
calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
|
|
@@ -93,9 +95,10 @@ module Samagotchi
|
|
|
93
95
|
outputs = split_outputs(joined, calls.length)
|
|
94
96
|
shown = saved_list(responses, :tool_params, calls.length)
|
|
95
97
|
labels = saved_list(responses, :tool_labels, calls.length)
|
|
98
|
+
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
96
99
|
images = split_images(responses, calls.length)
|
|
97
100
|
calls.each_with_index.map do |call, i|
|
|
98
|
-
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i])
|
|
101
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd)
|
|
99
102
|
end
|
|
100
103
|
end
|
|
101
104
|
|
|
@@ -117,14 +120,15 @@ module Samagotchi
|
|
|
117
120
|
counts.map { |n| images.shift(n) }
|
|
118
121
|
end
|
|
119
122
|
|
|
120
|
-
def native_tools(calls, responses, registry)
|
|
123
|
+
def native_tools(calls, responses, registry, cwd = nil)
|
|
121
124
|
by_id = responses.to_h { |r| [field(r, :tool_call_id), r] }
|
|
122
125
|
calls.each_with_index.map do |raw, i|
|
|
123
126
|
ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
|
|
124
127
|
call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
|
|
125
128
|
response = by_id[field(raw, :id)] || (field(responses[i], :tool_call_id).nil? ? responses[i] : nil)
|
|
126
129
|
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
127
|
-
response && field(response, :images), label: field(response, :tool_labels)
|
|
130
|
+
response && field(response, :images), label: field(response, :tool_labels),
|
|
131
|
+
diff: field(response, :tool_diffs), cwd: cwd)
|
|
128
132
|
end
|
|
129
133
|
end
|
|
130
134
|
|
|
@@ -145,10 +149,14 @@ module Samagotchi
|
|
|
145
149
|
end
|
|
146
150
|
|
|
147
151
|
# +shown+ is the saved params line, when it is a String.
|
|
148
|
-
|
|
152
|
+
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
153
|
+
# tool_diffs. +cwd+: the session's working directory, for the title.
|
|
154
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil)
|
|
149
155
|
name = call[:name].to_s
|
|
150
156
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
151
157
|
part = { tool: name, params: params.to_s }
|
|
158
|
+
title = ToolActivity.tool_title(name, call, cwd: cwd)
|
|
159
|
+
part[:title] = title if title
|
|
152
160
|
part[:label] = label if label.is_a?(String) && !label.empty?
|
|
153
161
|
unless output.nil?
|
|
154
162
|
part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
|
|
@@ -156,6 +164,7 @@ module Samagotchi
|
|
|
156
164
|
end
|
|
157
165
|
refs = Array(images).select { |ref| ref.is_a?(Hash) }
|
|
158
166
|
part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
|
|
167
|
+
part[:diff] = diff if diff.is_a?(Hash)
|
|
159
168
|
part
|
|
160
169
|
end
|
|
161
170
|
|
|
@@ -46,6 +46,7 @@ export function addStarted(model, event = {}) {
|
|
|
46
46
|
row.tool = event.tool || row.tool;
|
|
47
47
|
row.label = event.label || row.label;
|
|
48
48
|
row.params = event.params || row.params;
|
|
49
|
+
row.title = event.title || row.title;
|
|
49
50
|
return { row, created: false };
|
|
50
51
|
}
|
|
51
52
|
row = {
|
|
@@ -55,6 +56,10 @@ export function addStarted(model, event = {}) {
|
|
|
55
56
|
tool: event.tool || "tool",
|
|
56
57
|
label: event.label || null,
|
|
57
58
|
params: event.params || "",
|
|
59
|
+
// What the call did in a few words (the server's tool title: a
|
|
60
|
+
// project-relative path, a command without its "cd … &&"); rows show it
|
|
61
|
+
// in place of the params when there is one.
|
|
62
|
+
title: event.title || null,
|
|
58
63
|
status: ROW_STATUS.RUNNING,
|
|
59
64
|
output: "",
|
|
60
65
|
output_truncated: false,
|
|
@@ -78,6 +83,7 @@ export function addCompleted(model, event = {}) {
|
|
|
78
83
|
tool: event.tool,
|
|
79
84
|
label: event.label,
|
|
80
85
|
params: event.activity?.params || "",
|
|
86
|
+
title: event.activity?.title,
|
|
81
87
|
});
|
|
82
88
|
row = synthesized.row;
|
|
83
89
|
}
|
|
@@ -85,9 +91,13 @@ export function addCompleted(model, event = {}) {
|
|
|
85
91
|
row.output = event.output || "";
|
|
86
92
|
row.output_truncated = !!event.output_truncated;
|
|
87
93
|
if (event.activity?.params) row.params = event.activity.params;
|
|
94
|
+
if (event.activity?.title) row.title = event.activity.title;
|
|
88
95
|
// The images the tool returned (a reload in the turn view draws them from
|
|
89
96
|
// the saved parts, turn_view.js reloadRowHtml).
|
|
90
97
|
if (event.images?.length) row.images = event.images;
|
|
98
|
+
// What an edit/write changed in its file (a reload draws it from the
|
|
99
|
+
// saved parts, turn_view.js reloadRowHtml).
|
|
100
|
+
if (event.diff) row.diff = event.diff;
|
|
91
101
|
return row;
|
|
92
102
|
}
|
|
93
103
|
|