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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -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/desktop/macos/Panel.swift +16 -3
- 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 +29 -2
- data/lib/samagotchi/model_profile.rb +4 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +57 -4
- data/lib/samagotchi/self_report.rb +20 -2
- data/lib/samagotchi/send_command.rb +81 -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/delegate.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +26 -8
- 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 +186 -37
- 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 +86 -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,
|
|
@@ -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,
|
|
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)
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
66
|
-
|
|
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.
|
|
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
|
@@ -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
|
|
74
|
-
#
|
|
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,
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
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["
|
|
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
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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"
|
|
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)
|