samagotchi 0.4.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -99,6 +99,17 @@ module Samagotchi
99
99
  end
100
100
  end
101
101
 
102
+ # Whether a chat host's /props (+props+, Client::ServerProps) says its
103
+ # chat template takes no effort (llama.cpp's
104
+ # chat_template_caps.supports_reasoning_effort: false), so +level+'s
105
+ # reasoning_effort is ignored. false when it doesn't say.
106
+ def effort_ignored?(level, props)
107
+ return false unless EFFORTS.include?(level) && props&.answered? && props.body.is_a?(Hash)
108
+
109
+ caps = props.body["chat_template_caps"]
110
+ caps.is_a?(Hash) && caps["supports_reasoning_effort"] == false
111
+ end
112
+
102
113
  # The native prompt's switch for +level+ under +profile+.
103
114
  # @param profile [ModelProfile]
104
115
  # @return [Native]
@@ -31,13 +31,63 @@ module Samagotchi
31
31
  module_function
32
32
 
33
33
  # @param registry [Tools::Registry, nil] the session's tools
34
- def tool_activity_event(tool_name, call, result, registry: nil)
35
- {
34
+ # @param cwd [String, nil] what a file tool's title is relative to (the
35
+ # worker runs in its session's working directory)
36
+ def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd)
37
+ event = {
36
38
  action: tool_activity_action(tool_name, registry: registry),
37
39
  tool: tool_name,
38
40
  params: tool_activity_params(tool_name, call, registry: registry),
39
41
  status: tool_activity_status(result, tool_name)
40
42
  }
43
+ title = tool_title(tool_name, call, cwd: cwd)
44
+ event[:title] = title if title
45
+ event
46
+ end
47
+
48
+ # What the call did, for a web row's one line ("edit lib/a.rb", the
49
+ # command without its "cd … &&"): a file tool's path relative to +cwd+
50
+ # when it is under it, a command's first line, a memory's name; nil for
51
+ # the other tools (a plugin's row keeps its preview, the params).
52
+ # +params+ stays the full key=value line (the TUI, the guardrails).
53
+ def tool_title(tool_name, call, cwd: nil)
54
+ text = case tool_name
55
+ when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
56
+ when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
57
+ when Tools::Execute::NAME, Tools::TaskCreate::NAME then command_title(call[:content])
58
+ end
59
+ text = text.to_s.strip
60
+ return nil if text.empty?
61
+
62
+ case tool_name
63
+ when Tools::Read::NAME, Tools::Write::NAME, Tools::Edit::NAME
64
+ path = relative_path(text, cwd)
65
+ path.length > TOOL_ACTIVITY_PREVIEW_LIMIT ? "…#{path[-(TOOL_ACTIVITY_PREVIEW_LIMIT - 1)..]}" : path
66
+ else
67
+ text = text.gsub(/\s+/, " ")
68
+ text.length > TOOL_ACTIVITY_PREVIEW_LIMIT ? "#{text[0, TOOL_ACTIVITY_PREVIEW_LIMIT - 1]}…" : text
69
+ end
70
+ end
71
+
72
+ # A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
73
+ # eats the line.
74
+ LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
75
+
76
+ def command_title(command)
77
+ line = command.to_s.lines.map(&:strip).find { |l| !l.empty? }.to_s
78
+ line.sub(LEADING_CD, "")
79
+ end
80
+
81
+ # +path+ relative to +cwd+ when it is inside it, compared by path
82
+ # components ("/p/app-2" is not inside "/p/app"); else as given.
83
+ def relative_path(path, cwd)
84
+ return path if cwd.to_s.empty? || !path.start_with?("/")
85
+
86
+ parts = File.expand_path(path).split("/")
87
+ base = File.expand_path(cwd).split("/")
88
+ return path unless parts.length > base.length && parts[0, base.length] == base
89
+
90
+ parts.drop(base.length).join("/")
41
91
  end
42
92
 
43
93
  def tool_activity_action(tool_name, registry: nil)
@@ -48,6 +48,9 @@ module Samagotchi
48
48
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
49
49
  tool: call[:name], call: call.dup, params: params }
50
50
  started[:label] = label if label
51
+ # The worker runs in its session's working directory.
52
+ title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
53
+ started[:title] = title if title
51
54
  emit(on_stream_event, started)
52
55
 
53
56
  # The ask comes after tool_call_started: the UI shows the tool line,
@@ -195,8 +195,8 @@ module Samagotchi
195
195
  def self.output_block(label, content)
196
196
  return nil if content.nil? || content.empty?
197
197
 
198
- truncate_at_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_EXECUTE_TRUNCATE_AT_BYTES", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
199
- preview_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_EXECUTE_PREVIEW_BYTES", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
198
+ truncate_at_bytes = OutputGuardrails.config_positive_int("execute.truncate_at_bytes", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
199
+ preview_bytes = OutputGuardrails.config_positive_int("execute.preview_bytes", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
200
200
  bytes = content.bytesize
201
201
  return "#{label}:\n#{content}" if bytes <= truncate_at_bytes
202
202
 
@@ -220,7 +220,7 @@ module Samagotchi
220
220
  def self.telemetry_lines_for(content)
221
221
  OutputGuardrails.telemetry_lines_for(
222
222
  content: content,
223
- threshold_env: "SAMAGOTCHI_EXECUTE_TELEMETRY_THRESHOLD_PCT",
223
+ threshold_key: "execute.telemetry_threshold_pct",
224
224
  threshold_default: OutputGuardrails::DEFAULT_TELEMETRY_THRESHOLD_PCT,
225
225
  token_key: "estimated_tokens_for_command_output",
226
226
  pct_key: "estimated_window_pct_for_command_output"
@@ -13,13 +13,14 @@ module Samagotchi
13
13
 
14
14
  module_function
15
15
 
16
- def env_positive_int(key, default)
17
- value = ENV.fetch(key, default.to_s).to_i
16
+ # The config setting +key+ (read.*, execute.*, context.*) when positive, else +default+.
17
+ def config_positive_int(key, default)
18
+ value = Samagotchi::Config.get(key).to_i
18
19
  value.positive? ? value : default
19
20
  end
20
21
 
21
- def env_positive_float(key, default)
22
- value = ENV.fetch(key, default.to_s).to_f
22
+ def config_positive_float(key, default)
23
+ value = Samagotchi::Config.get(key).to_f
23
24
  value.positive? ? value : default
24
25
  end
25
26
 
@@ -57,10 +58,10 @@ module Samagotchi
57
58
  build_preview_parts(head: head, tail: tail, total_bytes: content.bytesize)
58
59
  end
59
60
 
60
- def telemetry_lines_for(content:, threshold_env:, threshold_default:, token_key:, pct_key:)
61
- chars_per_token = env_positive_float("SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN", DEFAULT_CHARS_PER_TOKEN)
61
+ def telemetry_lines_for(content:, threshold_key:, threshold_default:, token_key:, pct_key:)
62
+ chars_per_token = config_positive_float("context.chars_per_token", DEFAULT_CHARS_PER_TOKEN)
62
63
  window_tokens = ContextWindow.current.tokens
63
- telemetry_threshold_pct = env_positive_float(threshold_env, threshold_default)
64
+ telemetry_threshold_pct = config_positive_float(threshold_key, threshold_default)
64
65
 
65
66
  estimated_tokens = (content.length / chars_per_token).ceil
66
67
  estimated_window_pct = (estimated_tokens.to_f / window_tokens) * 100.0
@@ -52,9 +52,9 @@ module Samagotchi
52
52
 
53
53
  size = File.size(path)
54
54
 
55
- hard_max_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_HARD_MAX_BYTES", DEFAULT_HARD_MAX_BYTES)
56
- truncate_at_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_TRUNCATE_AT_BYTES", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
57
- preview_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_PREVIEW_BYTES", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
55
+ hard_max_bytes = OutputGuardrails.config_positive_int("read.hard_max_bytes", DEFAULT_HARD_MAX_BYTES)
56
+ truncate_at_bytes = OutputGuardrails.config_positive_int("read.truncate_at_bytes", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
57
+ preview_bytes = OutputGuardrails.config_positive_int("read.preview_bytes", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
58
58
 
59
59
  if size > hard_max_bytes
60
60
  return "Error: file too large: #{path} (#{size} bytes, hard limit #{hard_max_bytes} bytes)."
@@ -105,7 +105,7 @@ module Samagotchi
105
105
  def self.telemetry_lines_for(content)
106
106
  OutputGuardrails.telemetry_lines_for(
107
107
  content: content,
108
- threshold_env: "SAMAGOTCHI_READ_TELEMETRY_THRESHOLD_PCT",
108
+ threshold_key: "read.telemetry_threshold_pct",
109
109
  threshold_default: OutputGuardrails::DEFAULT_TELEMETRY_THRESHOLD_PCT,
110
110
  token_key: "estimated_tokens_for_preview",
111
111
  pct_key: "estimated_window_pct_for_preview"
@@ -9,6 +9,7 @@ require_relative "memory_bundle/shipped_update"
9
9
  require_relative "memory_bundle/system_bundle"
10
10
  require_relative "session"
11
11
  require_relative "version"
12
+ require_relative "web/lan"
12
13
 
13
14
  module Samagotchi
14
15
  # `chi update`: brings an installed chi up to date in one go: the system
@@ -64,7 +65,7 @@ module Samagotchi
64
65
  @platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
65
66
  @supported = supported
66
67
  @state_dir = state_dir
67
- @web = web || [Config.get("web.host").to_s, Config.get("web.port").to_i]
68
+ @web = web || [Web::Lan.local_host(Config.get("web.host")), Config.get("web.port").to_i]
68
69
  end
69
70
 
70
71
  # @return [Integer] 0 nothing failed, 1 a part failed (or a checkout), 2 usage
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
@@ -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