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