samagotchi 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -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/desktop/macos/Panel.swift +16 -3
  26. data/lib/samagotchi/engine.rb +50 -354
  27. data/lib/samagotchi/kernel_loop.rb +33 -44
  28. data/lib/samagotchi/live_versions.rb +7 -1
  29. data/lib/samagotchi/llm/errors.rb +17 -0
  30. data/lib/samagotchi/llm/http.rb +4 -18
  31. data/lib/samagotchi/llm/openai_chat.rb +29 -2
  32. data/lib/samagotchi/model_profile.rb +4 -10
  33. data/lib/samagotchi/note_command.rb +2 -1
  34. data/lib/samagotchi/reply_wait.rb +57 -4
  35. data/lib/samagotchi/self_report.rb +20 -2
  36. data/lib/samagotchi/send_command.rb +81 -6
  37. data/lib/samagotchi/session.rb +4 -2
  38. data/lib/samagotchi/session_manager.rb +18 -37
  39. data/lib/samagotchi/system_prompt.rb +403 -0
  40. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  41. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  42. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  43. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  44. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  45. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  46. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  47. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  48. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  49. data/lib/samagotchi/terminal_ui.rb +95 -690
  50. data/lib/samagotchi/thinking.rb +11 -0
  51. data/lib/samagotchi/tool_activity.rb +52 -2
  52. data/lib/samagotchi/tool_runner.rb +3 -0
  53. data/lib/samagotchi/tools/delegate.rb +2 -1
  54. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  55. data/lib/samagotchi/tools/execute.rb +3 -3
  56. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  57. data/lib/samagotchi/tools/read.rb +4 -4
  58. data/lib/samagotchi/update_command.rb +2 -1
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +186 -37
  61. data/lib/samagotchi/web/lan.rb +99 -0
  62. data/lib/samagotchi/web/message_parts.rb +15 -11
  63. data/lib/samagotchi/web/public/activity.js +7 -0
  64. data/lib/samagotchi/web/public/app.js +99 -54
  65. data/lib/samagotchi/web/public/chat_view.js +5 -1
  66. data/lib/samagotchi/web/public/index.html +163 -17
  67. data/lib/samagotchi/web/public/model_pick.js +136 -0
  68. data/lib/samagotchi/web/public/model_picker.js +224 -0
  69. data/lib/samagotchi/web/public/notify.js +10 -0
  70. data/lib/samagotchi/web/public/stage_model.js +110 -0
  71. data/lib/samagotchi/web/public/stage_view.js +580 -0
  72. data/lib/samagotchi/web/public/timing.js +6 -2
  73. data/lib/samagotchi/web/public/turn_events.js +9 -5
  74. data/lib/samagotchi/web/public/turn_model.js +11 -3
  75. data/lib/samagotchi/web/public/turn_view.js +86 -19
  76. data/lib/samagotchi/web/qr.rb +40 -0
  77. data/lib/samagotchi/web/server.rb +101 -11
  78. data/lib/samagotchi/web/token.rb +97 -0
  79. metadata +27 -3
  80. 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,
@@ -83,6 +83,7 @@ module Samagotchi
83
83
  child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
84
84
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
85
85
  parent_id: parent.id, state_dir: state_dir)
86
+ DelegateWait.mark_started(parent.id, child)
86
87
  child.id
87
88
  end
88
89
  private_class_method :start_child
@@ -100,7 +101,7 @@ module Samagotchi
100
101
  end
101
102
 
102
103
  # Only a reply after this delivery counts as the follow-up's.
103
- DelegateWait.mark_seen(parent.id, id, state_dir: state_dir)
104
+ DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
104
105
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
105
106
  state_dir: state_dir)
106
107
  case delivered[:status]
@@ -9,10 +9,12 @@ module Samagotchi
9
9
  module Tools
10
10
  # Waiting for a delegated session's next reply, shared by delegate and
11
11
  # delegate_result: ReplyWait in the tools' words, with a cursor per
12
- # child (the newest reply this parent was already given). The cursors
13
- # live in this process (a worker runs one session), keyed by parent and
14
- # child; a worker respawn loses them, which only repeats the newest reply
15
- # once.
12
+ # child (the newest reply this parent was already given) and the child
13
+ # as it was before the message went in (ReplyWait's baseline), so a turn
14
+ # that ends before the wait's first look (a fast failure) still ends it.
15
+ # Both live in this process (a worker runs one session), keyed by parent
16
+ # and child; a worker respawn loses them, which only repeats the newest
17
+ # reply once or waits for a later turn.
16
18
  module DelegateWait
17
19
  TIMEOUT_DEFAULT = 600
18
20
  POLL_INTERVAL = 0.5
@@ -28,6 +30,11 @@ module Samagotchi
28
30
  @seen ||= {}
29
31
  end
30
32
 
33
+ # @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
34
+ def baselines
35
+ @baselines ||= {}
36
+ end
37
+
31
38
  # @param child_id [String] the child's full id
32
39
  # @param peers [Peers] the parent (session_id, state_dir, cancelled?)
33
40
  # @param timeout [Integer] seconds
@@ -38,7 +45,9 @@ module Samagotchi
38
45
  key = [peers.session_id, child_id]
39
46
  cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
40
47
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
41
- poll_interval: poll_interval, cancelled: cancelled)
48
+ poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
49
+ # The turn sent to is handed over: a later wait looks for a later one.
50
+ baselines.delete(key) if %i[done no_reply].include?(wait.status)
42
51
  case wait.status
43
52
  when :done
44
53
  seen[key] = wait.file
@@ -61,9 +70,18 @@ module Samagotchi
61
70
  end
62
71
 
63
72
  # Point the cursor at the newest reply now, so only a later one counts
64
- # (a follow-up sent to a child that already answered).
65
- def mark_seen(parent_id, child_id, state_dir:)
66
- seen[[parent_id, child_id]] = ReplyWait.newest_reply(child_id, state_dir: state_dir)
73
+ # (a follow-up sent to a child that already answered), and take the
74
+ # child's baseline before the follow-up goes in.
75
+ # @param child [Session] the child as loaded before sending
76
+ def mark_seen(parent_id, child, state_dir:)
77
+ seen[[parent_id, child.id]] = ReplyWait.newest_reply(child.id, state_dir: state_dir)
78
+ baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child)
79
+ end
80
+
81
+ # A new child's baseline: as spawned, before its first turn ran.
82
+ # @param child [Session] the session spawn_session returned
83
+ def mark_started(parent_id, child)
84
+ baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
67
85
  end
68
86
 
69
87
  def reply_result(child_id, text)
@@ -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.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi/escape" # CGI.escapeHTML; Ruby 4.0 ships only cgi/escape
4
+ require "digest"
4
5
  require "fileutils"
5
6
  require "json"
6
7
  require "time"
@@ -70,9 +71,8 @@ module Samagotchi
70
71
 
71
72
  # @param bridge_wait_timeout [Float] bounded seconds to wait for a
72
73
  # 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
74
+ # @param view ["turn", "stage", "chat"] the page's view of a turn
75
+ # (web.view); ?view=turn|stage|chat overrides it for one page load
76
76
  # @param annotate_presets [String, Array] the quick replies next to
77
77
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
78
78
  # page parses them (annotate_presets.js).
@@ -82,11 +82,15 @@ module Samagotchi
82
82
  # (built from the config on first use)
83
83
  # @param models_wait_timeout [Float] bounded seconds GET /api/models
84
84
  # waits for the hosts' lists
85
+ # @param lan [Hash, nil] LAN mode (web.host: lan): { ip:, token: },
86
+ # the address the server also listens on and its access token (a
87
+ # String, or a Web::Token::Source that follows the file); nil:
88
+ # loopback only, no token
85
89
  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,
90
+ bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: "turn", hub: nil,
87
91
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
88
92
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
89
- registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT)
93
+ registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
90
94
  @manager = manager || SessionManager
91
95
  @registry = registry
92
96
  @models_wait_timeout = models_wait_timeout
@@ -97,12 +101,15 @@ module Samagotchi
97
101
  @public_dir = public_dir || File.expand_path("public", __dir__)
98
102
  @bridge_wait_timeout = bridge_wait_timeout
99
103
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
100
- @turn_view = turn_view
104
+ @view = view
101
105
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
102
106
  @hub = hub
103
107
  @events_heartbeat = events_heartbeat
104
108
  @events_queue = events_queue
105
109
  @server_running = -> { true }
110
+ @lan = lan
111
+ @refused_mutex = Mutex.new
112
+ @refused_logged = {}
106
113
  end
107
114
 
108
115
  def call(env)
@@ -116,7 +123,12 @@ module Samagotchi
116
123
 
117
124
  def route(env)
118
125
  req = Rack::Request.new(env)
119
- return forbidden unless localhost?(req)
126
+ return forbidden unless local_host_header?(env)
127
+ return cross_origin if cross_site?(env)
128
+ if @lan && !LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s)
129
+ denied = token_gate(req)
130
+ return denied if denied
131
+ end
120
132
 
121
133
  case [req.request_method, req.path_info]
122
134
  when ["GET", "/"], ["GET", "/index.html"]
@@ -199,15 +211,140 @@ module Samagotchi
199
211
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
200
212
  end
201
213
 
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)
214
+ LOOPBACK_NAMES = %w[127.0.0.1 [::1] localhost].freeze
215
+ LOOPBACK_PEERS = %w[127.0.0.1 ::1 ::ffff:127.0.0.1].freeze
216
+
217
+ # Only 127.0.0.1 / [::1] / localhost (any port), and in LAN mode the
218
+ # LAN address, are answered. The socket is bound to those, but a Host
219
+ # check stops DNS-rebinding pages. It reads the raw Host header:
220
+ # Rack's req.host trusts X-Forwarded-Host, which such a page could
221
+ # set. A request with no Host (HTTP/1.0) is answered only for a
222
+ # loopback peer.
223
+ def local_host_header?(env)
224
+ host = env["HTTP_HOST"].to_s
225
+ return LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s) if host.empty?
226
+
227
+ name = host.downcase.sub(/:\d*\z/, "")
228
+ LOOPBACK_NAMES.include?(name) || (!@lan.nil? && name == @lan[:ip])
229
+ end
230
+
231
+ TOKEN_COOKIE = "chi_token"
232
+ # 400 days, the longest a browser keeps a cookie (Chrome's cap). No
233
+ # Secure: the LAN page is plain http. Lax, not Strict: a token link
234
+ # opened from another site (a web messenger) makes its 303 chain
235
+ # cross-site, and a Strict cookie wouldn't ride on the redirected GET;
236
+ # the cross-site check already refuses what Strict would add.
237
+ TOKEN_COOKIE_ATTRIBUTES = "HttpOnly; SameSite=Lax; Path=/; Max-Age=34560000"
238
+ REFUSED_LOG_INTERVAL = 60.0
239
+
240
+ # LAN mode, a peer that isn't this machine (the peer is the socket's
241
+ # address, REMOTE_ADDR: never X-Forwarded-For or req.ip, which trust
242
+ # headers a phone could send): every route needs the access token, as
243
+ # the chi_token cookie or an Authorization: Bearer header. The page
244
+ # with ?token= trades it for the cookie and a 303 to itself without
245
+ # it, so the token doesn't stay in the phone's history.
246
+ # @return [Array, nil] the response that refuses it, or nil to go on
247
+ def token_gate(req)
248
+ token = current_token
249
+ if req.get? && ["/", "/index.html"].include?(req.path_info) && req.params.key?("token")
250
+ return token_redirect(req, token) if token_match?(req.params["token"], token)
251
+ else
252
+ auth = req.get_header("HTTP_AUTHORIZATION").to_s[/\ABearer +(\S+)\z/, 1]
253
+ return nil if token_match?(auth, token) || token_match?(req.cookies[TOKEN_COOKIE], token)
254
+ end
255
+
256
+ unauthorized(req)
257
+ end
258
+
259
+ def current_token
260
+ token = @lan[:token]
261
+ token.respond_to?(:current) ? token.current : token
262
+ end
263
+
264
+ def token_match?(given, token)
265
+ return false if given.nil? || given.empty? || token.nil? || token.empty?
266
+
267
+ Rack::Utils.secure_compare(given, token)
268
+ end
269
+
270
+ # The same page (and its other parameters, as they were written), the
271
+ # token traded for the cookie.
272
+ def token_redirect(req, token)
273
+ query = req.query_string.split("&").reject { |pair| pair.split("=", 2).first == "token" }.join("&")
274
+ location = query.empty? ? req.path_info : "#{req.path_info}?#{query}"
275
+ [303, { "Location" => location, "Set-Cookie" => "#{TOKEN_COOKIE}=#{token}; #{TOKEN_COOKIE_ATTRIBUTES}",
276
+ "Cache-Control" => "no-store", "Content-Length" => "0" }, []]
277
+ end
278
+
279
+ def unauthorized(req)
280
+ log_refused(req.get_header("REMOTE_ADDR").to_s, req.path_info)
281
+ return error_response(401, "unauthorized", "chi web on the LAN needs its access token") if req.path_info.start_with?("/api/")
282
+
283
+ body = UNAUTHORIZED_PAGE
284
+ [401, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" },
285
+ [body]]
286
+ end
287
+
288
+ # At most once a minute per peer: a phone with an old link retries.
289
+ def log_refused(peer, path)
290
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
291
+ due = @refused_mutex.synchronize do
292
+ @refused_logged.delete_if { |_, at| now - at >= REFUSED_LOG_INTERVAL } if @refused_logged.size > 64
293
+ last = @refused_logged[peer]
294
+ (last.nil? || now - last >= REFUSED_LOG_INTERVAL).tap { |fresh| @refused_logged[peer] = now if fresh }
295
+ end
296
+ Log.warn(:web, "unauthorized", peer: peer, path: path) if due
297
+ end
298
+
299
+ # What a phone sees without the token. The field is for a home-screen
300
+ # web app: it has its own cookies, and the QR code opens the browser.
301
+ UNAUTHORIZED_PAGE = <<~HTML
302
+ <!doctype html>
303
+ <html lang="en"><head><meta charset="utf-8">
304
+ <meta name="viewport" content="width=device-width, initial-scale=1">
305
+ <title>chi web: access token needed</title>
306
+ <style>
307
+ :root { color-scheme: light dark; }
308
+ body { font: 16px/1.5 -apple-system, system-ui, sans-serif; max-width: 30rem; margin: 3rem auto; padding: 0 1rem; }
309
+ input { font: inherit; width: 100%; box-sizing: border-box; padding: .5rem; margin: .5rem 0; }
310
+ button { font: inherit; padding: .4rem 1rem; }
311
+ </style></head>
312
+ <body>
313
+ <h1>chi web</h1>
314
+ <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>
315
+ <form method="get" action="/">
316
+ <label for="token">Or paste the token:</label>
317
+ <input id="token" name="token" autocomplete="off" autocapitalize="off" spellcheck="false">
318
+ <button type="submit">Open</button>
319
+ </form>
320
+ </body></html>
321
+ HTML
322
+
323
+ # Another website's page in the desktop browser can send "simple"
324
+ # requests here (a text/plain POST needs no preflight) and would start
325
+ # sessions that run commands. Refused: any request but GET/HEAD from
326
+ # another site (Sec-Fetch-Site cross-site/same-site, or an Origin other
327
+ # than exactly this server's: another local port and "null" are
328
+ # foreign), and GETs of /api/* the same way (no blind reads, no SSE
329
+ # subscriptions). The page itself is same-origin; curl, Net::HTTP and
330
+ # chi's own clients send neither header and pass. A link followed from
331
+ # another site still opens the page (a top-level GET).
332
+ def cross_site?(env)
333
+ foreign = %w[cross-site same-site].include?(env["HTTP_SEC_FETCH_SITE"].to_s.downcase)
334
+ origin = env["HTTP_ORIGIN"]
335
+ foreign ||= !origin.nil? && origin.downcase != "http://#{env["HTTP_HOST"].to_s.downcase}"
336
+ return false unless foreign
337
+
338
+ !%w[GET HEAD].include?(env["REQUEST_METHOD"]) || env["PATH_INFO"].to_s.start_with?("/api/")
339
+ end
340
+
341
+ def cross_origin
342
+ error_response(403, "cross_origin", "requests from other websites are refused")
207
343
  end
208
344
 
209
345
  def forbidden
210
- error_response(403, "forbidden", "only 127.0.0.1 is allowed")
346
+ names = @lan ? "127.0.0.1, [::1], localhost and #{@lan[:ip]}" : "127.0.0.1, [::1] and localhost"
347
+ error_response(403, "forbidden", "only the host names #{names} are answered")
211
348
  end
212
349
 
213
350
  # The page's scope: ?dir=<folder> lists that folder's project (every
@@ -248,7 +385,7 @@ module Samagotchi
248
385
  session_to_json(s, status: displayed_status(s, owner: owner), owner: owner, root_cache: roots)
249
386
  end
250
387
  # 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" => "*" }
388
+ headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store" }
252
389
  # Compute total without limit/offset for header
253
390
  if limit || offset.positive?
254
391
  total = if @state_dir
@@ -269,7 +406,7 @@ module Samagotchi
269
406
  all = @hub.snapshot(project_root: root, sort: sort, order: order)
270
407
  page = all.drop(offset)
271
408
  page = page.first(limit) if limit
272
- headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store", "Access-Control-Allow-Origin" => "*" }
409
+ headers = { "Content-Type" => "application/json; charset=utf-8", "Cache-Control" => "no-store" }
273
410
  headers["X-Total-Count"] = all.size.to_s if limit || offset.positive?
274
411
  body = JSON.generate(page)
275
412
  headers["Content-Length"] = body.bytesize.to_s
@@ -294,7 +431,7 @@ module Samagotchi
294
431
  # is chi web, and it knows ?dir (features).
295
432
  def handle_info
296
433
  json_response(200, { app: "chi-web", version: Samagotchi::VERSION, pid: Process.pid, cwd: Dir.pwd,
297
- features: ["dir"] })
434
+ features: ["dir"], lan: @lan && @lan[:ip] })
298
435
  end
299
436
 
300
437
  # The models a new session can start on, spelled as chi spells them
@@ -480,7 +617,8 @@ module Samagotchi
480
617
  json_response(200, {
481
618
  session: session_json,
482
619
  history: read_history(id),
483
- messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1"),
620
+ messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
621
+ cwd: session.respond_to?(:working_directory) ? session.working_directory : nil),
484
622
  failed_turn: failed_turn_for(session, raw_messages),
485
623
  current_turn: current_turn,
486
624
  queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
@@ -889,8 +1027,7 @@ module Samagotchi
889
1027
  "Content-Type" => "text/event-stream",
890
1028
  "Cache-Control" => "no-cache",
891
1029
  "Connection" => "keep-alive",
892
- "X-Accel-Buffering" => "no",
893
- "Access-Control-Allow-Origin" => "*"
1030
+ "X-Accel-Buffering" => "no"
894
1031
  }
895
1032
 
896
1033
  # Handlers that buffer enumerable bodies before responding (rackup's
@@ -924,8 +1061,7 @@ module Samagotchi
924
1061
  "Content-Type" => "text/event-stream",
925
1062
  "Cache-Control" => "no-cache",
926
1063
  "Connection" => "keep-alive",
927
- "X-Accel-Buffering" => "no",
928
- "Access-Control-Allow-Origin" => "*"
1064
+ "X-Accel-Buffering" => "no"
929
1065
  }
930
1066
  body = EventsBody.new(hub: @hub, project_root: root, heartbeat: @events_heartbeat, capacity: @events_queue,
931
1067
  server_running: @server_running)
@@ -1026,13 +1162,26 @@ module Samagotchi
1026
1162
  full = File.join(@public_dir, rel)
1027
1163
  if File.file?(full) && full.start_with?(@public_dir)
1028
1164
  body = File.binread(full)
1029
- ctype = mime_type(full)
1030
- [200, { "Content-Type" => ctype, "Content-Length" => body.bytesize.to_s, "Cache-Control" => "public, max-age=3600" }, [body]]
1165
+ # no-cache + a content ETag: the browser revalidates every asset
1166
+ # (the ES-module imports too) and gets a cheap 304 while it is
1167
+ # unchanged, so a gem upgrade + chi web restart takes effect on the
1168
+ # next load instead of after max-age runs out.
1169
+ etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
1170
+ headers = { "Cache-Control" => "no-cache", "ETag" => etag }
1171
+ return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
1172
+
1173
+ [200, headers.merge("Content-Type" => mime_type(full), "Content-Length" => body.bytesize.to_s), [body]]
1031
1174
  else
1032
1175
  not_found(path: req.path_info)
1033
1176
  end
1034
1177
  end
1035
1178
 
1179
+ def etag_match?(if_none_match, etag)
1180
+ return false if if_none_match.nil?
1181
+
1182
+ if_none_match.split(",").map { |t| t.strip.delete_prefix("W/") }.any? { |t| t == "*" || t == etag }
1183
+ end
1184
+
1036
1185
  def mime_type(path)
1037
1186
  case File.extname(path).downcase
1038
1187
  when ".html" then "text/html; charset=utf-8"
@@ -1159,7 +1308,9 @@ module Samagotchi
1159
1308
  # assistant message also carries what it did (MessageParts: thinking,
1160
1309
  # tool calls with params and output), and a message with nothing to
1161
1310
  # show but that (a text-less step) is kept with empty content
1162
- def messages_for_display(msgs, parts: false)
1311
+ # @param cwd [String, nil] the session's working directory (the parts'
1312
+ # tool titles are relative to it)
1313
+ def messages_for_display(msgs, parts: false, cwd: nil)
1163
1314
  filtered = []
1164
1315
  list = Array(msgs)
1165
1316
  list.each_with_index do |m, index|
@@ -1179,7 +1330,7 @@ module Samagotchi
1179
1330
  next if role == "tool_response"
1180
1331
 
1181
1332
  stripped = Samagotchi::OutputFormatter.strip_markup(content)
1182
- did = parts && %w[model assistant].include?(role) ? message_parts(list, index) : nil
1333
+ did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd) : nil
1183
1334
  next if stripped.empty? && did.nil?
1184
1335
 
1185
1336
  norm_role = role == "model" ? "assistant" : role
@@ -1222,9 +1373,9 @@ module Samagotchi
1222
1373
 
1223
1374
  # The parts of the assistant message at +index+, with the tool_response
1224
1375
  # messages right after it.
1225
- def message_parts(list, index)
1376
+ def message_parts(list, index, cwd = nil)
1226
1377
  responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
1227
- MessageParts.for_message(list[index], responses)
1378
+ MessageParts.for_message(list[index], responses, cwd: cwd)
1228
1379
  end
1229
1380
 
1230
1381
  # The last message messages_for_display shows as an answer, as a list of
@@ -1280,7 +1431,7 @@ module Samagotchi
1280
1431
  # nothing here: the list call answers 400 and the page says so.
1281
1432
  def index_data_attributes(req)
1282
1433
  attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
1283
- attrs["turn-view"] = "1" if turn_view?(req.params["view"])
1434
+ attrs["view"] = view_name(req.params["view"])
1284
1435
  # Always there: an empty one means no presets, not the default.
1285
1436
  attrs["annotate-presets"] = @annotate_presets
1286
1437
  dir, = scope_dir(req.params["dir"])
@@ -1301,19 +1452,17 @@ module Samagotchi
1301
1452
  attrs.map { |key, value| %(data-#{key}="#{Rack::Utils.escape_html(value)}") }.join(" ")
1302
1453
  end
1303
1454
 
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
1455
+ VIEWS = Config::BY_KEY["web.view"].enum_values
1456
+
1457
+ # The view for this page load: ?view=turn|stage|chat wins, anything
1458
+ # else leaves the config's choice (web.view).
1459
+ def view_name(param)
1460
+ VIEWS.include?(param) ? param : @view
1312
1461
  end
1313
1462
 
1314
1463
  def json_response(status, payload)
1315
1464
  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]]
1465
+ [status, { "Content-Type" => "application/json; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" }, [body]]
1317
1466
  end
1318
1467
 
1319
1468
  # The request body, "" when there is none: WEBrick (through rackup)