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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -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 turn_view [Boolean] the page's per-turn view (web.turn_view,
74
- # the default); false is the classic row of bubbles; ?view=turn|chat
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, turn_view: true, hub: nil,
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
- @turn_view = turn_view
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 localhost?(req)
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
- def localhost?(req)
203
- # Only 127.0.0.1 / ::1 / localhost are allowed. The socket is bound to
204
- # 127.0.0.1, but an explicit Host check prevents DNS-rebind tricks.
205
- host = req.host.to_s.downcase.split(":").first
206
- %w[127.0.0.1 ::1 localhost].include?(host)
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
- error_response(403, "forbidden", "only 127.0.0.1 is allowed")
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", "Access-Control-Allow-Origin" => "*" }
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", "Access-Control-Allow-Origin" => "*" }
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 ArgumentError => e
419
- return error_response(400, "invalid_model", e.message) if e.message.match?(/SAMAGOTCHI_DEFAULT_MODEL/)
420
- raise
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
- def messages_for_display(msgs, parts: false)
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["turn-view"] = "1" if turn_view?(req.params["view"])
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
- # The turn view for this page load: ?view=turn or ?view=chat wins,
1305
- # anything else leaves the config's choice.
1306
- def turn_view?(param)
1307
- case param
1308
- when "turn" then true
1309
- when "chat" then false
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", "Access-Control-Allow-Origin" => "*" }, [body]]
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
- # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, output:,
59
- # output_truncated:, images: }] } with only what it found; nil for nothing
60
- def for_message(message, responses, registry: Tools::Builtins.default)
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
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil)
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