samagotchi 0.110.0 → 0.111.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 +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/server.rb +26 -2
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +25 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -60,6 +60,11 @@ require_relative "principal"
|
|
|
60
60
|
require_relative "participant_access"
|
|
61
61
|
require_relative "participants_admin"
|
|
62
62
|
require_relative "participant_review"
|
|
63
|
+
require_relative "message_display"
|
|
64
|
+
require_relative "session_show"
|
|
65
|
+
require_relative "session_create"
|
|
66
|
+
require_relative "session_control"
|
|
67
|
+
require_relative "push_routes"
|
|
63
68
|
require_relative "proxy_gate"
|
|
64
69
|
require_relative "../log"
|
|
65
70
|
require_relative "../paths"
|
|
@@ -81,26 +86,16 @@ module Samagotchi
|
|
|
81
86
|
include ParticipantAccess
|
|
82
87
|
include ParticipantsAdmin
|
|
83
88
|
include ParticipantReview
|
|
89
|
+
include MessageDisplay
|
|
90
|
+
include SessionShow
|
|
91
|
+
include SessionCreate
|
|
92
|
+
include SessionControl
|
|
93
|
+
include PushRoutes
|
|
84
94
|
include ProxyGate
|
|
85
95
|
|
|
86
96
|
DEFAULT_HOST = BridgeClient::HOST
|
|
87
97
|
BRIDGE_WAIT_TIMEOUT = 10.0
|
|
88
|
-
# An uploaded image may be this big before it is downscaled.
|
|
89
|
-
MAX_IMAGE_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
90
|
-
# A POST /api/render body may be this big: a decision message is a few
|
|
91
|
-
# KB, and the cap bounds the work a page can ask for.
|
|
92
|
-
MAX_RENDER_BYTES = 64 * 1024
|
|
93
98
|
SESSION_ID_RE = /\A[0-9A-Za-z-]{1,64}\z/
|
|
94
|
-
IMAGE_NAME_RE = /\A[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z/
|
|
95
|
-
IMAGE_TYPES = { "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg", "gif" => "image/gif",
|
|
96
|
-
"webp" => "image/webp" }.freeze
|
|
97
|
-
|
|
98
|
-
# POST /stop waits this long for the worker to let go of the session
|
|
99
|
-
# when the body names no wait (the page's Stop: it shows the stop at
|
|
100
|
-
# once, and a Rack thread isn't held for it). A CLI through --remote
|
|
101
|
-
# sends its own, SessionManager::STOP_WAIT at most, so chi sessions
|
|
102
|
-
# stop waits as long as here.
|
|
103
|
-
STOP_WAIT_SECONDS = 2.0
|
|
104
99
|
|
|
105
100
|
# GET /api/events: a `: ping` this often while idle, a queue this deep
|
|
106
101
|
# per tab (an overflow ends the connection; the reconnect's snapshot
|
|
@@ -148,6 +143,8 @@ module Samagotchi
|
|
|
148
143
|
# (web.public_url, ProxyGate): every request needs a token
|
|
149
144
|
# @param public_token [String, Token::Source, nil] the access token
|
|
150
145
|
# with +public_url+ (the LAN one when both are set)
|
|
146
|
+
# @param push [Push::Runtime, nil] Web Push for this run (PushRoutes);
|
|
147
|
+
# nil: off (no https public_url, or web.push.enabled false)
|
|
151
148
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
152
149
|
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
|
|
153
150
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
@@ -155,7 +152,7 @@ module Samagotchi
|
|
|
155
152
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
156
153
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
|
|
157
154
|
working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
|
|
158
|
-
grants: nil, public_url: nil, public_token: nil)
|
|
155
|
+
grants: nil, public_url: nil, public_token: nil, push: nil)
|
|
159
156
|
@manager = manager || SessionManager
|
|
160
157
|
@registry = registry
|
|
161
158
|
@registry_given = !registry.nil?
|
|
@@ -179,6 +176,7 @@ module Samagotchi
|
|
|
179
176
|
@refused_mutex = Mutex.new
|
|
180
177
|
@refused_logged = {}
|
|
181
178
|
@wait_poll_interval = wait_poll_interval
|
|
179
|
+
@push = push
|
|
182
180
|
init_participant_access(grants, state_dir)
|
|
183
181
|
init_proxy_gate(public_url, public_token)
|
|
184
182
|
end
|
|
@@ -270,6 +268,10 @@ module Samagotchi
|
|
|
270
268
|
["GET", %r{\A/api/sessions/([^/]+)/apply\z}, :handle_apply, :owner, :session],
|
|
271
269
|
["GET", %r{\A/api/participants\z}, :handle_participants_list, :owner],
|
|
272
270
|
["DELETE", %r{\A/api/participants\z}, :handle_participants_revoke, :owner],
|
|
271
|
+
["GET", %r{\A/api/push\z}, :handle_push_info, :owner],
|
|
272
|
+
["POST", %r{\A/api/push/subscriptions\z}, :handle_push_subscribe, :owner],
|
|
273
|
+
["DELETE", %r{\A/api/push/subscriptions\z}, :handle_push_unsubscribe, :owner],
|
|
274
|
+
["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
|
|
273
275
|
["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
|
|
274
276
|
["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :owner, :session]
|
|
275
277
|
].freeze
|
|
@@ -405,7 +407,13 @@ module Samagotchi
|
|
|
405
407
|
# form) for the cookie.
|
|
406
408
|
TOKEN_TRADE_PATH = "/api/token"
|
|
407
409
|
TRADE_BODY_LIMIT = 4096
|
|
408
|
-
|
|
410
|
+
# Exact paths served before the token gate (still after the Host,
|
|
411
|
+
# cross-site and frame checks), none holding a secret: the 401 page's
|
|
412
|
+
# fragment trade script, and what a Home Screen app fetches without
|
|
413
|
+
# the cookie (Chrome fetches a manifest without credentials): the
|
|
414
|
+
# manifest, the service worker and the icons (Web Push).
|
|
415
|
+
GATE_FREE_PATHS = ["/token_fragment.js", "/manifest.webmanifest", "/sw.js", "/apple-touch-icon.png",
|
|
416
|
+
"/icons/chi-180.png", "/icons/chi-192.png", "/icons/chi-512.png"].freeze
|
|
409
417
|
|
|
410
418
|
# The request's Principal, as #route resolved it.
|
|
411
419
|
def principal(req) = req.env.fetch(PRINCIPAL_ENV)
|
|
@@ -976,756 +984,6 @@ module Samagotchi
|
|
|
976
984
|
n.positive? ? n : 0
|
|
977
985
|
end
|
|
978
986
|
|
|
979
|
-
def handle_create(req)
|
|
980
|
-
body = parse_json(request_body(req))
|
|
981
|
-
unless body.is_a?(Hash)
|
|
982
|
-
return error_response(400, "invalid_json", "invalid JSON body")
|
|
983
|
-
end
|
|
984
|
-
|
|
985
|
-
prompt = body["prompt"] || body[:prompt]
|
|
986
|
-
# idle: a session with no first turn, for a first message with
|
|
987
|
-
# images (the page uploads them into it, then sends the turn).
|
|
988
|
-
idle = body["idle"] == true
|
|
989
|
-
review, error = request_review(body)
|
|
990
|
-
return error if error
|
|
991
|
-
return handle_review_fork(body, review) if review&.from
|
|
992
|
-
|
|
993
|
-
if prompt.to_s.strip.empty? && !idle && !review
|
|
994
|
-
return error_response(400, "missing_fields", "prompt is required")
|
|
995
|
-
end
|
|
996
|
-
return handle_continue(body, prompt, idle) if body.key?("continues")
|
|
997
|
-
|
|
998
|
-
# dir: the folder the chat starts in (the page's scope); without it,
|
|
999
|
-
# the server's own cwd.
|
|
1000
|
-
dir, error = scope_dir(body["dir"])
|
|
1001
|
-
return error if error
|
|
1002
|
-
|
|
1003
|
-
# llm_context: the session's own LLM context values before its first
|
|
1004
|
-
# turn (the start page's llm ctx chip; chi --llm-context), read
|
|
1005
|
-
# before anything spawns so a bad one spawns nothing.
|
|
1006
|
-
llm_context, error = request_llm_context(body["llm_context"])
|
|
1007
|
-
return error if error
|
|
1008
|
-
|
|
1009
|
-
# thinking: the session's own thinking level (the start page's think
|
|
1010
|
-
# chip; chi --thinking), checked the same way.
|
|
1011
|
-
thinking, error = request_thinking(body["thinking"])
|
|
1012
|
-
return error if error
|
|
1013
|
-
|
|
1014
|
-
# settings: the session's own settings, flat as chi --set names
|
|
1015
|
-
# them ({"recap.enabled" => false, "cost-cap.cap" => 3}), checked
|
|
1016
|
-
# the same way (SessionConfig.from_request).
|
|
1017
|
-
begin
|
|
1018
|
-
settings = SessionConfig.from_request(body["settings"])
|
|
1019
|
-
rescue SessionConfig::Error => e
|
|
1020
|
-
return error_response(400, "invalid_settings", e.message)
|
|
1021
|
-
end
|
|
1022
|
-
|
|
1023
|
-
# toolkit, tools, no_tools: its toolkit (chi send --new --toolkit,
|
|
1024
|
-
# --tool, --no-tool: a name, tool names added and left out);
|
|
1025
|
-
# parent_answers: what a parent agent may answer on its questions
|
|
1026
|
-
# (hard, standard or soft; a looser one than config.yml's is capped,
|
|
1027
|
-
# and the 201's parent_answers_warning says so).
|
|
1028
|
-
toolkit, error = request_toolkit(body)
|
|
1029
|
-
return error if error
|
|
1030
|
-
|
|
1031
|
-
parent_answers, error = request_parent_answers(body["parent_answers"])
|
|
1032
|
-
return error if error
|
|
1033
|
-
|
|
1034
|
-
model = (body["model"] || body["model_name"]).to_s.strip
|
|
1035
|
-
refused = create_refusal(body, idle ? body["preview"] : prompt, values: [parent_answers], model: model)
|
|
1036
|
-
return refused if refused
|
|
1037
|
-
|
|
1038
|
-
folder = dir ? { working_directory: dir } : {}
|
|
1039
|
-
# model: the model the session starts on (the start page's picker,
|
|
1040
|
-
# spelled as GET /api/models lists it); blank means the default.
|
|
1041
|
-
folder[:model_name] = model unless model.empty?
|
|
1042
|
-
# preview: an idle session's first message, sent as its first turn
|
|
1043
|
-
# next; the list and the notifications name the session by it
|
|
1044
|
-
# until that turn is saved.
|
|
1045
|
-
preview = idle ? body["preview"].to_s.strip : ""
|
|
1046
|
-
folder[:preview] = preview unless preview.empty?
|
|
1047
|
-
setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings, toolkit: toolkit,
|
|
1048
|
-
parent_answers: parent_answers)
|
|
1049
|
-
folder[:setup] = setup unless setup.empty?
|
|
1050
|
-
begin
|
|
1051
|
-
session = if review
|
|
1052
|
-
ReviewStart.start(subjects: review.subjects, dir: dir || Dir.pwd, model: folder[:model_name],
|
|
1053
|
-
setup: setup, state_dir: @state_dir)
|
|
1054
|
-
else
|
|
1055
|
-
@manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
1056
|
-
end
|
|
1057
|
-
rescue ModelProfile::MissingModel => e
|
|
1058
|
-
# No model configured, or one qualified with an unknown host.
|
|
1059
|
-
return error_response(400, "invalid_model", e.message)
|
|
1060
|
-
rescue ReviewStart::Refused => e
|
|
1061
|
-
return error_response(400, "invalid_review", e.message)
|
|
1062
|
-
end
|
|
1063
|
-
# The worker's Bridge is the single live transport: wait (bounded) for
|
|
1064
|
-
# it so the client can attach without client-side polling.
|
|
1065
|
-
port = await_bridge_port(session.id)
|
|
1066
|
-
# The projection holds it before the page hears back (no tick wait).
|
|
1067
|
-
@hub&.touch(session.id)
|
|
1068
|
-
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
1069
|
-
json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning,
|
|
1070
|
-
parent_answers_warning: Guardrails::ParentPolicy.capped_note(parent_answers)))
|
|
1071
|
-
end
|
|
1072
|
-
|
|
1073
|
-
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
1074
|
-
# link of that session's chain (SessionManager.continue_session), in its
|
|
1075
|
-
# folder and with its own setup (SessionSetup: llm_context, thinking
|
|
1076
|
-
# and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
|
|
1077
|
-
# refused beside it (a create takes no reasoning_history). It runs on
|
|
1078
|
-
# model (or model_name) when given, else on the previous link's: one
|
|
1079
|
-
# whose host is gone falls back to the default, and the 201's
|
|
1080
|
-
# model_warning (null when none) says so. Its opener is the text the
|
|
1081
|
-
# page prefills (SessionChain.opener_for; null: the page's constant).
|
|
1082
|
-
# A model it can't start on is 400 invalid_model, nothing archived.
|
|
1083
|
-
# 409 continued (with next_id, the link that continues it already: the
|
|
1084
|
-
# page opens that one), open_children (ids), folder_gone, busy, scratch
|
|
1085
|
-
# or owned_by_tui.
|
|
1086
|
-
def handle_continue(body, prompt, idle)
|
|
1087
|
-
given = body["continues"]
|
|
1088
|
-
unless given.is_a?(String) && !given.strip.empty?
|
|
1089
|
-
return error_response(400, "invalid_continues", "continues is a session id")
|
|
1090
|
-
end
|
|
1091
|
-
if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
|
|
1092
|
-
return error_response(400, "invalid_continues",
|
|
1093
|
-
"continues takes the previous session's folder, llm_context, thinking, toolkit, " \
|
|
1094
|
-
"settings and parent_answers; leave out #{fields.join(", ")}")
|
|
1095
|
-
end
|
|
1096
|
-
|
|
1097
|
-
preview = idle ? body["preview"].to_s.strip : ""
|
|
1098
|
-
model = (body["model"] || body["model_name"]).to_s.strip
|
|
1099
|
-
refused = continue_refusal(given, body, idle ? body["preview"] : prompt, model: model)
|
|
1100
|
-
return refused if refused
|
|
1101
|
-
|
|
1102
|
-
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
|
|
1103
|
-
model: model.empty? ? nil : model)
|
|
1104
|
-
return error if error
|
|
1105
|
-
|
|
1106
|
-
port = await_bridge_port(session.id)
|
|
1107
|
-
moved = session.moved_children || []
|
|
1108
|
-
# The moved delegates' cards fold under the new link now.
|
|
1109
|
-
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
1110
|
-
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
1111
|
-
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
|
|
1112
|
-
model_warning: session.model_warning,
|
|
1113
|
-
opener: session.opener))
|
|
1114
|
-
end
|
|
1115
|
-
|
|
1116
|
-
# The create's review: {subjects: [paths], from: id} (chi review, the
|
|
1117
|
-
# page's Start review) as a SessionAccess::Review: [review, nil] (nil
|
|
1118
|
-
# when absent), or [nil, a 400 invalid_review]. A review session
|
|
1119
|
-
# starts idle: prompt, preview, idle and continues are refused beside
|
|
1120
|
-
# it. Its subjects are checked where it starts (ReviewStart).
|
|
1121
|
-
def request_review(body)
|
|
1122
|
-
return [nil, nil] unless body.key?("review")
|
|
1123
|
-
|
|
1124
|
-
raw = body["review"]
|
|
1125
|
-
subjects = raw.is_a?(Hash) ? raw.fetch("subjects", []) : nil
|
|
1126
|
-
from = raw.is_a?(Hash) ? raw["from"] : nil
|
|
1127
|
-
unless subjects.is_a?(Array) && subjects.all?(String) && (from.nil? || (from.is_a?(String) && !from.strip.empty?))
|
|
1128
|
-
return [nil, error_response(400, "invalid_review", "review is {subjects: [project files], from: a session id}")]
|
|
1129
|
-
end
|
|
1130
|
-
if (fields = REVIEW_REFUSES.select { |key| body.key?(key) }).any?
|
|
1131
|
-
return [nil, error_response(400, "invalid_review", "a review session starts idle; leave out #{fields.join(", ")}")]
|
|
1132
|
-
end
|
|
1133
|
-
|
|
1134
|
-
[SessionAccess::Review.new(subjects: subjects, from: from&.strip), nil]
|
|
1135
|
-
end
|
|
1136
|
-
|
|
1137
|
-
# What a review session's create refuses beside review.
|
|
1138
|
-
REVIEW_REFUSES = %w[prompt preview idle continues].freeze
|
|
1139
|
-
|
|
1140
|
-
# POST /api/sessions with review.from: a review fork of that session
|
|
1141
|
-
# (ReviewStart.fork), in its folder and with its setup (dir and the
|
|
1142
|
-
# setup fields refused beside it, as for a continue), on model when
|
|
1143
|
-
# given. 201 as a create's; 400 invalid_review (a subject, its folder
|
|
1144
|
-
# gone), invalid_model, ambiguous_id; 404 not_found.
|
|
1145
|
-
def handle_review_fork(body, review)
|
|
1146
|
-
if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
|
|
1147
|
-
return error_response(400, "invalid_review",
|
|
1148
|
-
"a review of a session takes its folder and setup; leave out #{fields.join(", ")}")
|
|
1149
|
-
end
|
|
1150
|
-
|
|
1151
|
-
model = (body["model"] || body["model_name"]).to_s.strip
|
|
1152
|
-
begin
|
|
1153
|
-
session = ReviewStart.fork(from: review.from, subjects: review.subjects, model: model.empty? ? nil : model,
|
|
1154
|
-
state_dir: @state_dir)
|
|
1155
|
-
rescue ReviewStart::Refused => e
|
|
1156
|
-
return error_response(400, "invalid_review", e.message)
|
|
1157
|
-
rescue ModelProfile::MissingModel => e
|
|
1158
|
-
return error_response(400, "invalid_model", e.message)
|
|
1159
|
-
rescue Session::AmbiguousId => e
|
|
1160
|
-
return refusal_response(400, "ambiguous_id", e, ids: e.ids)
|
|
1161
|
-
rescue ArgumentError => e
|
|
1162
|
-
return error_response(404, "not_found", e.message)
|
|
1163
|
-
end
|
|
1164
|
-
port = await_bridge_port(session.id)
|
|
1165
|
-
@hub&.touch(session.id)
|
|
1166
|
-
json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning))
|
|
1167
|
-
end
|
|
1168
|
-
|
|
1169
|
-
# chi send's client ids: its lines are checked as chi send checks them
|
|
1170
|
-
# (#parent_gate).
|
|
1171
|
-
CLI_SEND_IDS = [ClientId::CLI_SEND, ClientId::CLI_ANSWER].freeze
|
|
1172
|
-
|
|
1173
|
-
# What a continue takes from the previous link: refused beside it.
|
|
1174
|
-
CONTINUE_TAKES = %w[dir llm_context thinking settings toolkit tools no_tools parent_answers].freeze
|
|
1175
|
-
|
|
1176
|
-
# The previous link's parent_answers and the model the new one runs,
|
|
1177
|
-
# for #create_refusal; nil when the previous link isn't there
|
|
1178
|
-
# (continue_from says so).
|
|
1179
|
-
def continue_refusal(given, body, line, model:)
|
|
1180
|
-
return nil unless body["check_vision"] == true || CLI_SEND_IDS.include?(body["client_id"])
|
|
1181
|
-
|
|
1182
|
-
previous = Session.load(SessionChain.resolve(given, state_dir: default_state_dir), state_dir: default_state_dir)
|
|
1183
|
-
create_refusal(body, line, values: [previous.parent_answers], model: model, previous: previous)
|
|
1184
|
-
rescue ArgumentError, ModelProfile::MissingModel
|
|
1185
|
-
nil
|
|
1186
|
-
end
|
|
1187
|
-
|
|
1188
|
-
# A create refused before anything spawns, as chi send --new refuses
|
|
1189
|
-
# it (SendCommand::ParentGate, the vision check): its first line
|
|
1190
|
-
# (+line+, the prompt or an idle one's preview) when it is a parent
|
|
1191
|
-
# agent's /continue yes or a /parent-answers that loosens +values+
|
|
1192
|
-
# (#parent_gate), and with check_vision: true (its first message
|
|
1193
|
-
# brings images) a model known not to take them (422 no_vision).
|
|
1194
|
-
# @param previous [Session, nil] a continue's previous link: its model
|
|
1195
|
-
# decides when +model+ is blank
|
|
1196
|
-
# @return [Array, nil] the response, or nil to go on
|
|
1197
|
-
def create_refusal(body, line, values:, model:, previous: nil)
|
|
1198
|
-
refused = parent_gate(line, client_id: body["client_id"], values: values)
|
|
1199
|
-
return refused if refused
|
|
1200
|
-
return nil unless body["check_vision"] == true
|
|
1201
|
-
|
|
1202
|
-
name, typed = if previous
|
|
1203
|
-
SendCommand.continued_model(previous, model)
|
|
1204
|
-
else
|
|
1205
|
-
[model.empty? ? nil : model, nil]
|
|
1206
|
-
end
|
|
1207
|
-
vision_refused(name, typed)
|
|
1208
|
-
end
|
|
1209
|
-
|
|
1210
|
-
# A line from chi send (client_id cli:send, or cli:answer for a parent
|
|
1211
|
-
# agent's /continue yes) refused here as chi send refuses it on its own
|
|
1212
|
-
# machine (SendCommand::ParentGate), held to this server's config.yml:
|
|
1213
|
-
# a parent's /continue yes that guardrails.parent_answers (tightened by
|
|
1214
|
-
# +values+, the targets' own parent_answers) refuses, 403
|
|
1215
|
-
# parent_continue_refused; a /parent-answers line that loosens one of
|
|
1216
|
-
# +values+, 403 parent_answers_refused (loosening is the user's at a
|
|
1217
|
-
# UI). The page's lines (web:…) and others aren't checked here; the
|
|
1218
|
-
# worker checks every line again.
|
|
1219
|
-
# @return [Array, nil] the response, or nil to go on
|
|
1220
|
-
def parent_gate(line, client_id:, values:)
|
|
1221
|
-
line = line.to_s
|
|
1222
|
-
return nil unless CLI_SEND_IDS.include?(client_id)
|
|
1223
|
-
|
|
1224
|
-
if client_id == ClientId::CLI_ANSWER && TurnFlow.continue_yes?(line) &&
|
|
1225
|
-
(refusal = SendCommand::ParentGate.continue_refusal_for(values))
|
|
1226
|
-
return error_response(403, "parent_continue_refused", refusal)
|
|
1227
|
-
end
|
|
1228
|
-
return nil unless SendCommand::ParentGate.loosens_any?(line, values)
|
|
1229
|
-
|
|
1230
|
-
error_response(403, "parent_answers_refused", ParentAnswersCommand::REFUSED)
|
|
1231
|
-
end
|
|
1232
|
-
|
|
1233
|
-
# 422 no_vision in chi send's words when the model (+name+, nil: the
|
|
1234
|
-
# default) is known not to take images; nil when it does or is
|
|
1235
|
-
# unknown (the worker decides when the turn runs).
|
|
1236
|
-
def vision_refused(name, typed)
|
|
1237
|
-
refusal = SendCommand.vision_refusal(name, SendCommand.vision_answer(name, typed: typed))
|
|
1238
|
-
refusal && error_response(422, "no_vision", refusal)
|
|
1239
|
-
end
|
|
1240
|
-
|
|
1241
|
-
# The create request's toolkit (a name), tools and no_tools (names, a
|
|
1242
|
-
# comma list each too), as chi send --new's --toolkit, --tool and
|
|
1243
|
-
# --no-tool: [toolkit, nil] (nil for none), or [nil, a 400 response].
|
|
1244
|
-
def request_toolkit(body)
|
|
1245
|
-
name = body["toolkit"]
|
|
1246
|
-
lists = %w[tools no_tools].to_h { |key| [key, body[key]] }
|
|
1247
|
-
unless (name.nil? || name.is_a?(String)) &&
|
|
1248
|
-
lists.values.all? { |list| list.nil? || (list.is_a?(Array) && list.all?(String)) }
|
|
1249
|
-
return [nil, error_response(400, "invalid_toolkit", "toolkit is a name; tools and no_tools are lists of names")]
|
|
1250
|
-
end
|
|
1251
|
-
|
|
1252
|
-
[Toolkit.from_flags(name: name, add: lists["tools"] || [], remove: lists["no_tools"] || []), nil]
|
|
1253
|
-
end
|
|
1254
|
-
|
|
1255
|
-
# The create request's parent_answers (hard, standard or soft;
|
|
1256
|
-
# "default" or none: config.yml's): [level, nil] or [nil, a 400].
|
|
1257
|
-
def request_parent_answers(raw)
|
|
1258
|
-
return [nil, nil] if raw.nil?
|
|
1259
|
-
|
|
1260
|
-
level = raw.is_a?(String) ? Guardrails::ParentPolicy.parse_level(raw) : :invalid
|
|
1261
|
-
return [nil, nil] if level == :unset
|
|
1262
|
-
unless Guardrails::ParentPolicy::ORDER.include?(level)
|
|
1263
|
-
return [nil, error_response(400, "invalid_parent_answers",
|
|
1264
|
-
"parent_answers takes #{Guardrails::ParentPolicy::ORDER.join(", ")}")]
|
|
1265
|
-
end
|
|
1266
|
-
|
|
1267
|
-
[level, nil]
|
|
1268
|
-
end
|
|
1269
|
-
|
|
1270
|
-
# SessionManager.continue_session, its refusals as responses: [session,
|
|
1271
|
-
# nil] or [nil, the response]. Only its own errors: what fails after
|
|
1272
|
-
# the new link started is not a refusal of it.
|
|
1273
|
-
def continue_from(given, prompt:, preview:, model:)
|
|
1274
|
-
[@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
|
|
1275
|
-
rescue SessionContinue::Refused => e
|
|
1276
|
-
extra = e.reason == :continued ? { next_id: e.ids.first } : {}
|
|
1277
|
-
[nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
|
|
1278
|
-
rescue SessionManager::ArchiveRefused => e
|
|
1279
|
-
[nil, archive_refused(e)]
|
|
1280
|
-
rescue SessionManager::OwnedByTUI => e
|
|
1281
|
-
[nil, owned_by_repl(given, e)]
|
|
1282
|
-
rescue ModelProfile::MissingModel => e
|
|
1283
|
-
[nil, error_response(400, "invalid_model", e.message)]
|
|
1284
|
-
rescue Session::AmbiguousId => e
|
|
1285
|
-
[nil, refusal_response(400, "ambiguous_id", e, ids: e.ids)]
|
|
1286
|
-
rescue ArgumentError => e
|
|
1287
|
-
[nil, error_response(404, "not_found", e.message)]
|
|
1288
|
-
end
|
|
1289
|
-
|
|
1290
|
-
# The create request's "llm_context" ({strategy:, apply:, budget:},
|
|
1291
|
-
# each a word as /llm-context takes it, "default" for unset) as an
|
|
1292
|
-
# override: [override, nil] (nil when absent or all default), or
|
|
1293
|
-
# [nil, a 400 response] for anything else.
|
|
1294
|
-
def request_llm_context(raw)
|
|
1295
|
-
return [nil, nil] if raw.nil?
|
|
1296
|
-
|
|
1297
|
-
fields = LLMContextOverride::COMMAND_WORDS.invert
|
|
1298
|
-
unless raw.is_a?(Hash) && raw.all? { |key, word| fields.key?(key) && word.is_a?(String) }
|
|
1299
|
-
return [nil, error_response(400, "invalid_llm_context",
|
|
1300
|
-
"llm_context is an object of #{fields.keys.join(", ")} words")]
|
|
1301
|
-
end
|
|
1302
|
-
|
|
1303
|
-
override = LLMContextOverride.update(nil, raw.to_h { |key, word| [fields.fetch(key), word] })
|
|
1304
|
-
[override.empty? ? nil : override, nil]
|
|
1305
|
-
rescue ArgumentError => e
|
|
1306
|
-
[nil, error_response(400, "invalid_llm_context", e.message)]
|
|
1307
|
-
end
|
|
1308
|
-
|
|
1309
|
-
# The create request's "thinking" (a level word as /thinking takes it,
|
|
1310
|
-
# "default" for none) as the session's own level: [level, nil] (nil
|
|
1311
|
-
# when absent or default), or [nil, a 400 response] for anything else.
|
|
1312
|
-
def request_thinking(raw)
|
|
1313
|
-
return [nil, nil] if raw.nil?
|
|
1314
|
-
|
|
1315
|
-
word = raw.is_a?(String) ? raw.strip.downcase : nil
|
|
1316
|
-
unless word && Thinking::LEVELS.map(&:to_s).include?(word)
|
|
1317
|
-
return [nil, error_response(400, "invalid_thinking", "thinking is one of #{Thinking::LEVELS.join(", ")}")]
|
|
1318
|
-
end
|
|
1319
|
-
|
|
1320
|
-
[Thinking.session_level(word), nil]
|
|
1321
|
-
end
|
|
1322
|
-
|
|
1323
|
-
def handle_show(req, id)
|
|
1324
|
-
return participant_show(id) unless principal(req).owner?
|
|
1325
|
-
|
|
1326
|
-
# The page's light re-reads: never the whole conversation.
|
|
1327
|
-
return handle_cards_read(id) if req.params["cards"] == "1"
|
|
1328
|
-
return handle_tail_read(req, id) if req.params["tail"] == "1"
|
|
1329
|
-
return handle_timing_read(id) if req.params["timing"] == "1"
|
|
1330
|
-
return handle_brief_read(id) if req.params["brief"] == "1"
|
|
1331
|
-
|
|
1332
|
-
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1333
|
-
# Read-only preview: selecting a session never spawns a worker.
|
|
1334
|
-
# The worker is woken only on POST /turn (handle_turn) via
|
|
1335
|
-
# SessionManager.resume_session + write_turn_input. This avoids
|
|
1336
|
-
# replaying last_prompt and spamming workers on preview scrub.
|
|
1337
|
-
# last_event_seq is nil when no live bridge, so the frontend stays
|
|
1338
|
-
# silent until the first Send.
|
|
1339
|
-
#
|
|
1340
|
-
# A live worker's snapshot is the truth (the session file lags its
|
|
1341
|
-
# Engine): the messages, the turn in progress and the prompts queued
|
|
1342
|
-
# behind it, all at one event_seq the client streams on from.
|
|
1343
|
-
live = bridge_get_json(id, "snapshot")
|
|
1344
|
-
turn_snapshot = live && live["snapshot"]
|
|
1345
|
-
snapshot = live && live["session_state_snapshot"]
|
|
1346
|
-
last_event_seq = live_or_saved(snapshot, "event_seq") { bridge_event_seq(id) }
|
|
1347
|
-
current_turn = turn_snapshot && turn_snapshot["current_turn"]
|
|
1348
|
-
owner = session_owner(id)
|
|
1349
|
-
pending = shown_pending_question(session, turn_snapshot, owner)
|
|
1350
|
-
# What the worker's server said it served for that model (after a turn);
|
|
1351
|
-
# without a worker, what the session's analytics.json last saved.
|
|
1352
|
-
persisted = snapshot ? nil : read_analytics(id)
|
|
1353
|
-
session_json = shown_session_json(session, snapshot, owner, persisted)
|
|
1354
|
-
raw_messages = live_or_saved(turn_snapshot, "messages") { session.messages }
|
|
1355
|
-
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
1356
|
-
json_response(200, {
|
|
1357
|
-
session: session_json,
|
|
1358
|
-
history: read_history(id),
|
|
1359
|
-
messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
|
|
1360
|
-
cwd: session.working_directory),
|
|
1361
|
-
failed_turn: failed_turn_for(session, raw_messages),
|
|
1362
|
-
current_turn: current_turn,
|
|
1363
|
-
queued: snapshot_list(turn_snapshot, "queued"),
|
|
1364
|
-
# Commands waiting for the running turn's end (their bubbles).
|
|
1365
|
-
queued_commands: snapshot_list(turn_snapshot, "queued_commands"),
|
|
1366
|
-
recap: turn_snapshot && turn_snapshot["recap"],
|
|
1367
|
-
saved_recap: saved_recap_for(id, session, turn_snapshot),
|
|
1368
|
-
continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
|
|
1369
|
-
guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
|
|
1370
|
-
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
1371
|
-
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
1372
|
-
init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
|
|
1373
|
-
cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }, cwd: session.working_directory),
|
|
1374
|
-
# The composer's / autocomplete; the built-ins until a worker
|
|
1375
|
-
# names its plugins' too.
|
|
1376
|
-
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
1377
|
-
markdown_warning: @markdown_renderer.warning,
|
|
1378
|
-
**viewer_capabilities(req),
|
|
1379
|
-
pending_question: pending,
|
|
1380
|
-
last_event_seq: last_event_seq,
|
|
1381
|
-
# The stream cursor `<seq>-<epoch>`: a later worker resets it
|
|
1382
|
-
# instead of taking the seq as its own. Only the snapshot has it.
|
|
1383
|
-
last_event_id: turn_snapshot && turn_snapshot["event_id"],
|
|
1384
|
-
timing: timing
|
|
1385
|
-
})
|
|
1386
|
-
end
|
|
1387
|
-
|
|
1388
|
-
# A list in the worker's turn +snapshot+ (queued prompts, commands,
|
|
1389
|
-
# init tasks); [] without a worker.
|
|
1390
|
-
def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
|
|
1391
|
-
|
|
1392
|
-
# A live worker's +key+ as its reading (+live+: a snapshot, a tail)
|
|
1393
|
-
# says it, nil included; without a worker (+live+ nil) the block's,
|
|
1394
|
-
# the server's own reading of the saved session. A nil value from a
|
|
1395
|
-
# worker is not a missing one (contrast SessionSetting#snapshot_value's
|
|
1396
|
-
# :or).
|
|
1397
|
-
def live_or_saved(live, key)
|
|
1398
|
-
live ? live[key] : yield
|
|
1399
|
-
end
|
|
1400
|
-
|
|
1401
|
-
# The file's question only while a worker owns the session: one a
|
|
1402
|
-
# dead worker saved can't be answered (its turn is gone), nor a chi
|
|
1403
|
-
# REPL's (it shares nothing with the web).
|
|
1404
|
-
# A question between turns (the step-limit one) is the snapshot's own.
|
|
1405
|
-
# Its refs (the asking reply's, QuestionAsked) are checked again.
|
|
1406
|
-
def shown_pending_question(session, turn_snapshot, owner)
|
|
1407
|
-
pending = if turn_snapshot
|
|
1408
|
-
current_turn = turn_snapshot["current_turn"]
|
|
1409
|
-
current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
|
|
1410
|
-
elsif owner&.worker?
|
|
1411
|
-
session.pending_question&.to_h
|
|
1412
|
-
end
|
|
1413
|
-
with_question_refs(pending, cwd: session.working_directory)
|
|
1414
|
-
end
|
|
1415
|
-
|
|
1416
|
-
# A pending question (a snapshot's, the session file's or a saved
|
|
1417
|
-
# card's) with its refs checked again, under a question's caps
|
|
1418
|
-
# (QuestionAsked): a stored file is never trusted, and what isn't
|
|
1419
|
-
# valid any more is dropped. The question as it was without refs.
|
|
1420
|
-
def with_question_refs(pending, cwd:)
|
|
1421
|
-
return pending unless pending.is_a?(Hash)
|
|
1422
|
-
|
|
1423
|
-
key = [:refs, "refs"].find { |name| pending.key?(name) } or return pending
|
|
1424
|
-
refs = Samagotchi::AnswerRefs.for_display(pending[key], cwd: cwd, max_refs: Samagotchi::QuestionAsked::MAX_REFS,
|
|
1425
|
-
max_bytes: Samagotchi::QuestionAsked::MAX_BYTES)
|
|
1426
|
-
refs.empty? ? pending.except(key) : pending.merge(key => refs)
|
|
1427
|
-
end
|
|
1428
|
-
|
|
1429
|
-
# The full show's session part: the file's, with what a live worker's
|
|
1430
|
-
# state +snapshot+ knows first (its model, status, served model,
|
|
1431
|
-
# prompt notes and chips); without one, the served model from
|
|
1432
|
-
# +persisted+ (analytics.json). Sets a worker's model on +session+:
|
|
1433
|
-
# a /model in the worker changes it before the file catches up.
|
|
1434
|
-
def shown_session_json(session, snapshot, owner, persisted)
|
|
1435
|
-
session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
|
|
1436
|
-
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
1437
|
-
if snapshot
|
|
1438
|
-
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
|
|
1439
|
-
served_expected_by: snapshot["served_expected_by"])
|
|
1440
|
-
# The model notes its prompt carries now (a /model there changes them).
|
|
1441
|
-
session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
|
|
1442
|
-
else
|
|
1443
|
-
session_json = session_json.merge(saved_served_model(session, persisted))
|
|
1444
|
-
end
|
|
1445
|
-
# The per-session settings' chips (the info bar's llm ctx, think,
|
|
1446
|
-
# reasoning and tools): the worker's, else worked out here from the
|
|
1447
|
-
# session file (SessionSetting#snapshot_value, #saved_summary).
|
|
1448
|
-
SessionSetting::ALL.each do |setting|
|
|
1449
|
-
chip = setting.snapshot_value(snapshot) { setting.saved_summary(session, registry: host_registry) }
|
|
1450
|
-
session_json = session_json.merge(setting.key => chip)
|
|
1451
|
-
end
|
|
1452
|
-
session_json
|
|
1453
|
-
end
|
|
1454
|
-
|
|
1455
|
-
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
1456
|
-
# body (the stream carries the card's plain text only). The worker's
|
|
1457
|
-
# cards from its light GET tail, else the saved ones; the session file
|
|
1458
|
-
# is only stat'ed (the 404), never parsed.
|
|
1459
|
-
def handle_cards_read(id)
|
|
1460
|
-
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
1461
|
-
|
|
1462
|
-
tail = live_tail(id)
|
|
1463
|
-
json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
|
|
1464
|
-
end
|
|
1465
|
-
|
|
1466
|
-
# ?tail=1: the page's re-read at the end of a turn wants the final
|
|
1467
|
-
# answer's rendered markdown, the status and the timing, not the whole
|
|
1468
|
-
# history again (nor a render of every earlier answer). Live: the
|
|
1469
|
-
# worker's GET tail alone (no session file read); else the file. The
|
|
1470
|
-
# session part is what the page reads of it (id, status, used memory
|
|
1471
|
-
# names, the model notes its prompt carried: the first turn's build or
|
|
1472
|
-
# a /model records them). ?turn_id=: that turn's answer (a queued
|
|
1473
|
-
# turn's page re-reads after the next one started). The timing's records
|
|
1474
|
-
# are the newest turn's (#recent_timing), which the page merges by id
|
|
1475
|
-
# (the page still sends ?recent=1, which is accepted and not needed).
|
|
1476
|
-
def handle_tail_read(req, id)
|
|
1477
|
-
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
1478
|
-
|
|
1479
|
-
turn_id = req.params["turn_id"].to_s.strip
|
|
1480
|
-
turn_id = nil if turn_id.empty?
|
|
1481
|
-
tail = live_tail(id, turn_id: turn_id)
|
|
1482
|
-
state = tail && tail["session_state_snapshot"]
|
|
1483
|
-
state = nil unless state.is_a?(Hash)
|
|
1484
|
-
if state
|
|
1485
|
-
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
1486
|
-
answer = tail["answer"]
|
|
1487
|
-
cwd = tail_working_directory(id, tail, answer)
|
|
1488
|
-
answer = without_file_refs(answer) unless cwd
|
|
1489
|
-
messages = last_assistant_for_display([answer].compact, cwd: cwd)
|
|
1490
|
-
else
|
|
1491
|
-
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1492
|
-
status = displayed_status(session)
|
|
1493
|
-
used = session.used_memory_names
|
|
1494
|
-
notes = session.prompt_notes.map(&:to_h)
|
|
1495
|
-
messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
|
|
1496
|
-
end
|
|
1497
|
-
session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
|
|
1498
|
-
live_metrics = state && state["metrics"]
|
|
1499
|
-
timing = recent_timing(id, live_metrics)
|
|
1500
|
-
json_response(200, {
|
|
1501
|
-
tail: true,
|
|
1502
|
-
session: session_json,
|
|
1503
|
-
messages: messages,
|
|
1504
|
-
markdown_warning: @markdown_renderer.warning,
|
|
1505
|
-
**viewer_capabilities(req),
|
|
1506
|
-
timing: timing
|
|
1507
|
-
})
|
|
1508
|
-
end
|
|
1509
|
-
|
|
1510
|
-
# The folder the live tail's answer's file refs must stay under: the
|
|
1511
|
-
# worker's (its tail's working_directory), else the hub's session list
|
|
1512
|
-
# entry's (a worker from before the field, or read through its
|
|
1513
|
-
# snapshot), else, only when the answer has refs to check, the session
|
|
1514
|
-
# file's, as the full read takes it. nil when none is known: the
|
|
1515
|
-
# caller drops the file refs rather than show them unchecked.
|
|
1516
|
-
# @return [String, nil]
|
|
1517
|
-
def tail_working_directory(id, tail, answer)
|
|
1518
|
-
known = ->(dir) { dir.is_a?(String) && !dir.empty? }
|
|
1519
|
-
cwd = tail["working_directory"]
|
|
1520
|
-
cwd = @hub&.working_directory(id) unless known.call(cwd)
|
|
1521
|
-
return cwd if known.call(cwd)
|
|
1522
|
-
return nil unless answer.is_a?(Hash) && (answer[AnswerRefs::KEY.to_s] || answer[AnswerRefs::KEY])
|
|
1523
|
-
|
|
1524
|
-
cwd = @session_class.load(id, state_dir: default_state_dir).working_directory
|
|
1525
|
-
known.call(cwd) ? cwd : nil
|
|
1526
|
-
rescue ArgumentError, KeyError, SystemCallError
|
|
1527
|
-
nil
|
|
1528
|
-
end
|
|
1529
|
-
|
|
1530
|
-
# +answer+ without its file refs (the rest kept), for a read that
|
|
1531
|
-
# knows no folder to check them against.
|
|
1532
|
-
def without_file_refs(answer)
|
|
1533
|
-
return answer unless answer.is_a?(Hash)
|
|
1534
|
-
|
|
1535
|
-
key = [AnswerRefs::KEY.to_s, AnswerRefs::KEY].find { |name| answer[name].is_a?(Array) } or return answer
|
|
1536
|
-
answer.merge(key => answer[key].reject { |ref| ref.is_a?(Hash) && (ref["kind"] || ref[:kind]).to_s == Ref::FILE })
|
|
1537
|
-
end
|
|
1538
|
-
|
|
1539
|
-
# ?timing=1: the whole timing (every record, analytics.json merged with
|
|
1540
|
-
# the live ones) and turn_count, when the page's merge comes up short.
|
|
1541
|
-
# The worker's GET state, never its messages.
|
|
1542
|
-
def handle_timing_read(id)
|
|
1543
|
-
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
1544
|
-
|
|
1545
|
-
state = bridge_get_json(id, "state")
|
|
1546
|
-
json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
|
|
1547
|
-
end
|
|
1548
|
-
|
|
1549
|
-
def session_exists?(id)
|
|
1550
|
-
@session_class.exist?(id, state_dir: default_state_dir)
|
|
1551
|
-
rescue ArgumentError
|
|
1552
|
-
false
|
|
1553
|
-
end
|
|
1554
|
-
|
|
1555
|
-
# The worker's light read (Bridge GET tail): {"session_state_snapshot",
|
|
1556
|
-
# "working_directory", "answer", "cards", "event_seq", "event_id"}. A
|
|
1557
|
-
# worker from before the route answers 404 not_found: its GET snapshot,
|
|
1558
|
-
# in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
|
|
1559
|
-
# 500, a timeout) is nil: the caller reads the disk, with no second
|
|
1560
|
-
# try at a worker that failed once.
|
|
1561
|
-
# @return [Hash, nil]
|
|
1562
|
-
def live_tail(id, turn_id: nil)
|
|
1563
|
-
status, body = bridge_get(id, turn_id ? "tail?turn_id=#{URI.encode_www_form_component(turn_id)}" : "tail")
|
|
1564
|
-
return body if status == 200 && body.is_a?(Hash)
|
|
1565
|
-
return nil unless status == 404 && body.is_a?(Hash) && body["error"] == "not_found"
|
|
1566
|
-
|
|
1567
|
-
status, live = bridge_get(id, "snapshot")
|
|
1568
|
-
return nil unless status == 200 && live.is_a?(Hash) && live["snapshot"].is_a?(Hash)
|
|
1569
|
-
|
|
1570
|
-
snap = live["snapshot"]
|
|
1571
|
-
{ "session_state_snapshot" => live["session_state_snapshot"],
|
|
1572
|
-
"answer" => AnswerTail.find(snap["messages"], turn_id: turn_id),
|
|
1573
|
-
"cards" => snap["cards"], "event_seq" => snap["event_seq"], "event_id" => snap["event_id"] }
|
|
1574
|
-
end
|
|
1575
|
-
|
|
1576
|
-
# The worker's last cards and between-turns notices (Bridge
|
|
1577
|
-
# cards: CardStore#list), else (no live worker) the ones the last
|
|
1578
|
-
# worker saved in the session's folder (#saved_cards), a card's body as
|
|
1579
|
-
# body_html: rendered markdown when the renderer is on, else the
|
|
1580
|
-
# escaped text in a <pre>, and a question card's refs checked again
|
|
1581
|
-
# (#with_question_refs; +cwd+ the session's folder, nil where the
|
|
1582
|
-
# session file isn't read: a file ref then stays only absolute).
|
|
1583
|
-
# @return [Array<Hash>]
|
|
1584
|
-
def cards_for_display(cards, cwd: nil)
|
|
1585
|
-
Array(cards).filter_map do |card|
|
|
1586
|
-
next unless card.is_a?(Hash)
|
|
1587
|
-
if card["type"].to_s == "question" && card["pending_question"].is_a?(Hash)
|
|
1588
|
-
next card.merge("pending_question" => with_question_refs(card["pending_question"], cwd: cwd))
|
|
1589
|
-
end
|
|
1590
|
-
next card unless card["type"].to_s == "card"
|
|
1591
|
-
|
|
1592
|
-
card.merge("body_html" => card_body_html(card["body"].to_s))
|
|
1593
|
-
end
|
|
1594
|
-
end
|
|
1595
|
-
|
|
1596
|
-
# As JSON has them (string keys and values), like a snapshot's.
|
|
1597
|
-
def saved_cards(id)
|
|
1598
|
-
JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
|
|
1599
|
-
rescue StandardError
|
|
1600
|
-
[]
|
|
1601
|
-
end
|
|
1602
|
-
|
|
1603
|
-
def card_body_html(body)
|
|
1604
|
-
return "" if body.strip.empty?
|
|
1605
|
-
|
|
1606
|
-
html = @markdown_renderer.render(body) if @markdown_renderer.available?
|
|
1607
|
-
html || "<pre>#{CGI.escapeHTML(body)}</pre>"
|
|
1608
|
-
end
|
|
1609
|
-
|
|
1610
|
-
# POST /api/render {text}: the page's markdown read for a text it
|
|
1611
|
-
# already shows (the stage's asking text, a resolved question card's
|
|
1612
|
-
# reply), session-free. {html: nil} when markdown is off or
|
|
1613
|
-
# commonmarker is missing: the page keeps the raw text.
|
|
1614
|
-
def handle_render(req)
|
|
1615
|
-
too_large = error_response(413, "too_large", "a text to render may be up to #{MAX_RENDER_BYTES / 1024} KB")
|
|
1616
|
-
return too_large if req.content_length.to_i > MAX_RENDER_BYTES
|
|
1617
|
-
|
|
1618
|
-
raw = request_body(req, MAX_RENDER_BYTES + 1).to_s
|
|
1619
|
-
return too_large if raw.bytesize > MAX_RENDER_BYTES
|
|
1620
|
-
|
|
1621
|
-
body = parse_json(raw)
|
|
1622
|
-
return error_response(400, "invalid_json", "give {text}") unless body.is_a?(Hash) && body["text"].is_a?(String)
|
|
1623
|
-
|
|
1624
|
-
html = @markdown_renderer.render(body["text"]) if @markdown_renderer.available?
|
|
1625
|
-
json_response(200, { html: html })
|
|
1626
|
-
end
|
|
1627
|
-
|
|
1628
|
-
def handle_question_answer(req, id)
|
|
1629
|
-
session = @session_class.load(id, state_dir: default_state_dir) rescue nil
|
|
1630
|
-
return error_response(404, "not_found", "Session not found: #{id}") unless session
|
|
1631
|
-
|
|
1632
|
-
body = parse_json(request_body(req))
|
|
1633
|
-
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
1634
|
-
|
|
1635
|
-
qid = body["id"]
|
|
1636
|
-
return error_response(400, "missing_fields", "id is required") if qid.to_s.strip.empty?
|
|
1637
|
-
|
|
1638
|
-
# client_id: only chi answer's (a parent agent's answer, which the
|
|
1639
|
-
# worker holds to its guardrails.parent_answers) is passed on; any
|
|
1640
|
-
# other is dropped, so a forward can only tighten (an answer never
|
|
1641
|
-
# passes for the user's own by it).
|
|
1642
|
-
client_id = body["client_id"] == ClientId::CLI_ANSWER ? ClientId::CLI_ANSWER : nil
|
|
1643
|
-
request = once_more_on_late do |client|
|
|
1644
|
-
client.answer(id: qid, selected: body["selected"], freeform: body["freeform"], client_id: client_id)
|
|
1645
|
-
end
|
|
1646
|
-
relay(id, bridge_client(id), request, what: "the answer was not sent", cant: "answer questions") do |reply|
|
|
1647
|
-
case reply.status
|
|
1648
|
-
when 200 then json_response(200, { status: "answered", session_id: id, id: qid })
|
|
1649
|
-
# Another client answered first (or the question was cancelled).
|
|
1650
|
-
when 409 then error_response(409, "question_not_pending", reply_detail(reply, "question not pending"))
|
|
1651
|
-
when 400 then error_response(400, "invalid_answer", reply_detail(reply, "answer rejected"))
|
|
1652
|
-
# The worker's guardrails.parent_answers refused a parent's option.
|
|
1653
|
-
when 403 then json_response(403, { error: "parent_answer_refused", reason: reply.json&.dig("reason"),
|
|
1654
|
-
detail: reply_detail(reply, "the worker refused that option") })
|
|
1655
|
-
end
|
|
1656
|
-
end
|
|
1657
|
-
end
|
|
1658
|
-
|
|
1659
|
-
# A request to the worker sent once more when the worker dropped it as
|
|
1660
|
-
# past its deadline (408), as chi answer does on this machine
|
|
1661
|
-
# (SessionAccess::Local#answer); a second 408 is #relay's 504
|
|
1662
|
-
# worker_timeout. The question stays open either way.
|
|
1663
|
-
def once_more_on_late
|
|
1664
|
-
lambda do |client|
|
|
1665
|
-
reply = yield client
|
|
1666
|
-
reply.status == 408 ? yield(client) : reply
|
|
1667
|
-
end
|
|
1668
|
-
end
|
|
1669
|
-
|
|
1670
|
-
# Leave the pending question unanswered (the card's Dismiss). A question
|
|
1671
|
-
# only exists while its worker waits on it, so this needs a live bridge.
|
|
1672
|
-
def handle_question_dismiss(req, id)
|
|
1673
|
-
body = parse_json(request_body(req))
|
|
1674
|
-
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
1675
|
-
|
|
1676
|
-
qid = body["id"].to_s
|
|
1677
|
-
return error_response(400, "missing_fields", "id is required") if qid.strip.empty?
|
|
1678
|
-
|
|
1679
|
-
request = once_more_on_late { |client| client.dismiss_question(id: qid) }
|
|
1680
|
-
relay(id, bridge_client(id), request, what: "the question was not dismissed", cant: "dismiss questions") do |reply|
|
|
1681
|
-
case reply.status
|
|
1682
|
-
when 200 then json_response(200, { status: "dismissed", session_id: id, id: qid })
|
|
1683
|
-
when 409
|
|
1684
|
-
# The step-limit question can't be left unanswered.
|
|
1685
|
-
code = reply.json&.dig("error") == "not_dismissable" ? "not_dismissable" : "question_not_pending"
|
|
1686
|
-
error_response(409, code, reply_detail(reply, "question not pending"))
|
|
1687
|
-
end
|
|
1688
|
-
end
|
|
1689
|
-
end
|
|
1690
|
-
|
|
1691
|
-
# A session command typed in the composer (/model, /models, !rollback,
|
|
1692
|
-
# !cmd, /continue): the worker runs it (woken as for a turn) and every
|
|
1693
|
-
# UI gets its :command_ran. Needs the worker's Bridge.
|
|
1694
|
-
def handle_command(req, id)
|
|
1695
|
-
body = parse_json(request_body(req))
|
|
1696
|
-
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
1697
|
-
|
|
1698
|
-
line = body["line"].to_s.strip
|
|
1699
|
-
return error_response(400, "missing_fields", "line is required") if line.empty?
|
|
1700
|
-
|
|
1701
|
-
@manager.resume_session(id, state_dir: @state_dir)
|
|
1702
|
-
# card: a card's action; its command events say so (no echo).
|
|
1703
|
-
request = ->(client) { client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true) }
|
|
1704
|
-
relay(id, live_bridge_client(id), request, what: "the command was not run", cant: "run commands") do |reply|
|
|
1705
|
-
case reply.status
|
|
1706
|
-
when 202
|
|
1707
|
-
record_history(body, line) if PromptHistory.shell_line?(line)
|
|
1708
|
-
json_response(202, reply.json || { status: "accepted" })
|
|
1709
|
-
when 400
|
|
1710
|
-
# One of this chi's own commands the worker doesn't know: it runs an
|
|
1711
|
-
# older chi. command: which one, so a remote TUI names it as its
|
|
1712
|
-
# Bridge's own 400 unknown_command would (WebBridgeClient).
|
|
1713
|
-
name = reply.json&.dig("error") == "unknown_command" && SessionCommands.builtin_name(line)
|
|
1714
|
-
next older_worker_command(id, name) if name
|
|
1715
|
-
|
|
1716
|
-
error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
|
|
1717
|
-
end
|
|
1718
|
-
end
|
|
1719
|
-
rescue SessionManager::OwnedByTUI => e
|
|
1720
|
-
owned_by_tui(e)
|
|
1721
|
-
end
|
|
1722
|
-
|
|
1723
|
-
# 501 not_supported for a builtin +name+ ("/title") the worker lacks.
|
|
1724
|
-
def older_worker_command(id, name)
|
|
1725
|
-
words = BridgeClient.stale_worker_message(id, cant: "run #{name}")
|
|
1726
|
-
json_response(501, { error: "not_supported", detail: words, message: words, command: name })
|
|
1727
|
-
end
|
|
1728
|
-
|
|
1729
987
|
# Hand a request to the session's live Bridge (+client+, nil without
|
|
1730
988
|
# one) and map its reply. The block gets the reply and answers the
|
|
1731
989
|
# statuses its route knows (nil for the rest); every relay maps the
|
|
@@ -1759,26 +1017,6 @@ module Samagotchi
|
|
|
1759
1017
|
not_live(id)
|
|
1760
1018
|
end
|
|
1761
1019
|
|
|
1762
|
-
# 409 owned_by_tui in the words a refused archive, delete or continue
|
|
1763
|
-
# shows (stop, restart, a turn and a command show OwnedByTUI's own).
|
|
1764
|
-
def owned_by_repl(id, error = nil)
|
|
1765
|
-
named = SessionManager::OwnedByTUI.new(id)
|
|
1766
|
-
error ||= named
|
|
1767
|
-
refusal_response(409, "owned_by_tui", error, detail: named.message, session_id: error.session_id)
|
|
1768
|
-
end
|
|
1769
|
-
|
|
1770
|
-
# 409 owned_by_tui in OwnedByTUI's own words (stop, restart, a turn, a
|
|
1771
|
-
# command).
|
|
1772
|
-
def owned_by_tui(error) = refusal_response(409, "owned_by_tui", error, session_id: error.session_id)
|
|
1773
|
-
|
|
1774
|
-
# 409 scratch or busy (every other reason: a turn, a queued prompt, an
|
|
1775
|
-
# open step-limit question, in it or a delegate, +busy_id+), with the
|
|
1776
|
-
# reason for a remote client.
|
|
1777
|
-
def archive_refused(error)
|
|
1778
|
-
refusal_response(409, error.reason == :scratch ? "scratch" : "busy", error, session_id: error.session_id,
|
|
1779
|
-
reason: error.reason.to_s, busy_id: error.busy_id)
|
|
1780
|
-
end
|
|
1781
|
-
|
|
1782
1020
|
def not_live(id)
|
|
1783
1021
|
error_response(503, "not_live", "no live bridge for session #{id}")
|
|
1784
1022
|
end
|
|
@@ -1805,139 +1043,6 @@ module Samagotchi
|
|
|
1805
1043
|
error_response(504, "worker_timeout", "the session's worker did not answer, so #{what}")
|
|
1806
1044
|
end
|
|
1807
1045
|
|
|
1808
|
-
def handle_output(req, id)
|
|
1809
|
-
# Query ?since= may be an ISO8601 time or numeric mtime; we treat it as Time parse.
|
|
1810
|
-
since_param = req.params["since"]
|
|
1811
|
-
since_time = parse_since(since_param)
|
|
1812
|
-
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1813
|
-
raw = @manager.read_responses(id, since_time: since_time, state_dir: @state_dir)
|
|
1814
|
-
# Strip wire tokens server-side so the client never sees <|tool_call> etc.
|
|
1815
|
-
rendered = raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
|
|
1816
|
-
json_response(200, { session_id: session.id, output: rendered })
|
|
1817
|
-
end
|
|
1818
|
-
|
|
1819
|
-
def handle_turn(req, id)
|
|
1820
|
-
body = parse_json(request_body(req))
|
|
1821
|
-
unless body.is_a?(Hash)
|
|
1822
|
-
return error_response(400, "invalid_json", "invalid JSON body")
|
|
1823
|
-
end
|
|
1824
|
-
# A participant's review turn: its own path, which takes from the
|
|
1825
|
-
# body only the prompt (ParticipantAccess#handle_participant_turn).
|
|
1826
|
-
return handle_participant_turn(req, id, body) unless principal(req).owner?
|
|
1827
|
-
|
|
1828
|
-
prompt = body["prompt"] || body[:prompt]
|
|
1829
|
-
client_id = body["client_id"] || body[:client_id]
|
|
1830
|
-
if prompt.to_s.strip.empty?
|
|
1831
|
-
return error_response(400, "missing_fields", "prompt is required")
|
|
1832
|
-
end
|
|
1833
|
-
|
|
1834
|
-
images = turn_images(id, body["images"])
|
|
1835
|
-
return error_response(400, "bad_images", images) if images.is_a?(String)
|
|
1836
|
-
|
|
1837
|
-
delivery = body["delivery"] || body[:delivery]
|
|
1838
|
-
if (refused = Delivery.refusal(delivery))
|
|
1839
|
-
return error_response(400, refused[:error], refused[:detail])
|
|
1840
|
-
end
|
|
1841
|
-
|
|
1842
|
-
refused = turn_refusal(id, body, prompt.to_s, images)
|
|
1843
|
-
return refused if refused
|
|
1844
|
-
|
|
1845
|
-
# no_interrupt: an attached TUI's --no-interrupt (chi --remote --attach).
|
|
1846
|
-
# author: the principal's (nil for the owner), never the body's.
|
|
1847
|
-
result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
|
|
1848
|
-
delivery: delivery, no_interrupt: body["no_interrupt"] == true,
|
|
1849
|
-
author: principal(req).author,
|
|
1850
|
-
state_dir: @state_dir, manager: @manager,
|
|
1851
|
-
bridge: -> { live_bridge_client(id) })
|
|
1852
|
-
case result.status
|
|
1853
|
-
when :accepted
|
|
1854
|
-
record_history(body, prompt)
|
|
1855
|
-
# via: bridge or file (the worker was gone: its input file), which
|
|
1856
|
-
# names the turn a remote chi send --wait follows (WaitTarget).
|
|
1857
|
-
json_response(202, result.ack.merge("via" => result.via.to_s))
|
|
1858
|
-
when :refused then json_response(result.code, result.ack)
|
|
1859
|
-
when :timeout then error_response(504, "worker_timeout", result.detail)
|
|
1860
|
-
else error_response(500, "enqueue_failed", "could not write turn input")
|
|
1861
|
-
end
|
|
1862
|
-
rescue SessionManager::OwnedByTUI => e
|
|
1863
|
-
owned_by_tui(e)
|
|
1864
|
-
end
|
|
1865
|
-
|
|
1866
|
-
# A turn refused before it goes in, as chi send refuses it on its own
|
|
1867
|
-
# machine: a chi send line #parent_gate refuses (the session's own
|
|
1868
|
-
# parent_answers), and with check_vision: true images to a model known
|
|
1869
|
-
# not to take them (422 no_vision). The page sends neither.
|
|
1870
|
-
# @return [Array, nil] the response, or nil to go on
|
|
1871
|
-
def turn_refusal(id, body, prompt, images)
|
|
1872
|
-
vision = body["check_vision"] == true && !images.empty?
|
|
1873
|
-
return nil unless vision || CLI_SEND_IDS.include?(body["client_id"])
|
|
1874
|
-
|
|
1875
|
-
session = @session_class.load(id, state_dir: default_state_dir)
|
|
1876
|
-
refused = parent_gate(prompt, client_id: body["client_id"], values: [session.parent_answers])
|
|
1877
|
-
return refused if refused
|
|
1878
|
-
|
|
1879
|
-
vision ? vision_refused(session.model_name, session.model_typed) : nil
|
|
1880
|
-
end
|
|
1881
|
-
|
|
1882
|
-
# A turn's images as [{file:, name:}] (refs to uploads already in the
|
|
1883
|
-
# session's images/), or a String saying what's wrong. Never a path.
|
|
1884
|
-
def turn_images(id, raw)
|
|
1885
|
-
return [] if raw.nil?
|
|
1886
|
-
|
|
1887
|
-
session_dir = image_session_dir(id)
|
|
1888
|
-
return "unknown session" unless session_dir
|
|
1889
|
-
|
|
1890
|
-
ImageStore.check_refs(session_dir, raw)
|
|
1891
|
-
end
|
|
1892
|
-
|
|
1893
|
-
# The session's folder, or nil for an id that isn't one.
|
|
1894
|
-
def image_session_dir(id)
|
|
1895
|
-
return nil unless SESSION_ID_RE.match?(id.to_s)
|
|
1896
|
-
|
|
1897
|
-
@session_class.load(id, state_dir: default_state_dir)
|
|
1898
|
-
Session.session_dir(id, state_dir: default_state_dir)
|
|
1899
|
-
rescue ArgumentError
|
|
1900
|
-
nil
|
|
1901
|
-
end
|
|
1902
|
-
|
|
1903
|
-
# POST /api/sessions/:id/images: the raw image as the body (a paste or
|
|
1904
|
-
# a drop), ?name= for its file name. Stored like any other image
|
|
1905
|
-
# (converted, downscaled); answers the ref the turn then names.
|
|
1906
|
-
def handle_image_upload(req, id)
|
|
1907
|
-
session_dir = image_session_dir(id)
|
|
1908
|
-
return error_response(404, "not_found", "unknown session") unless session_dir
|
|
1909
|
-
|
|
1910
|
-
length = req.content_length.to_i
|
|
1911
|
-
if length > MAX_IMAGE_UPLOAD_BYTES
|
|
1912
|
-
return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
|
|
1913
|
-
end
|
|
1914
|
-
|
|
1915
|
-
bytes = request_body(req, MAX_IMAGE_UPLOAD_BYTES + 1).to_s.b
|
|
1916
|
-
if bytes.bytesize > MAX_IMAGE_UPLOAD_BYTES
|
|
1917
|
-
return error_response(413, "too_large", "an image may be up to #{MAX_IMAGE_UPLOAD_BYTES / 1024 / 1024} MB")
|
|
1918
|
-
end
|
|
1919
|
-
|
|
1920
|
-
name = File.basename(req.params["name"].to_s)[0, 120]
|
|
1921
|
-
ref = ImageStore.ingest(session_dir, bytes: bytes, name: name.empty? ? "image" : name, source: "user")
|
|
1922
|
-
json_response(201, ref)
|
|
1923
|
-
rescue ImageStore::Error => e
|
|
1924
|
-
error_response(422, "bad_image", e.message)
|
|
1925
|
-
end
|
|
1926
|
-
|
|
1927
|
-
# GET /api/sessions/:id/images/<hash>.<ext>: a stored image (only
|
|
1928
|
-
# raster types; never svg), for the page's thumbnails.
|
|
1929
|
-
def handle_image(_req, id, name)
|
|
1930
|
-
session_dir = image_session_dir(id)
|
|
1931
|
-
return not_found(path: "images/#{name}") unless session_dir && IMAGE_NAME_RE.match?(name.to_s)
|
|
1932
|
-
|
|
1933
|
-
path = File.join(session_dir, ImageStore::DIR, name)
|
|
1934
|
-
return not_found(path: "images/#{name}") unless File.file?(path) && !File.symlink?(path)
|
|
1935
|
-
|
|
1936
|
-
body = File.binread(path)
|
|
1937
|
-
[200, { "Content-Type" => IMAGE_TYPES.fetch(File.extname(name).delete(".")), "Content-Length" => body.bytesize.to_s,
|
|
1938
|
-
"X-Content-Type-Options" => "nosniff", "Cache-Control" => "private, max-age=86400" }, [body]]
|
|
1939
|
-
end
|
|
1940
|
-
|
|
1941
1046
|
# The session's Bridge, waiting briefly for one a resume just spawned.
|
|
1942
1047
|
# @return [BridgeClient, nil]
|
|
1943
1048
|
def live_bridge_client(id)
|
|
@@ -1952,159 +1057,6 @@ module Samagotchi
|
|
|
1952
1057
|
nil
|
|
1953
1058
|
end
|
|
1954
1059
|
|
|
1955
|
-
# The Bridge decides the reason (Bridge::CANCEL_REASONS; anything
|
|
1956
|
-
# else is manual) and the reply echoes its choice. No reason: user.
|
|
1957
|
-
def handle_cancel(req, id)
|
|
1958
|
-
@session_class.load(id, state_dir: default_state_dir)
|
|
1959
|
-
|
|
1960
|
-
parsed = parse_json(request_body(req))
|
|
1961
|
-
reason = (parsed["reason"] if parsed.is_a?(Hash)).to_s.strip
|
|
1962
|
-
reason = "user" if reason.empty?
|
|
1963
|
-
|
|
1964
|
-
request = ->(client) { client.cancel(reason: reason) }
|
|
1965
|
-
relay(id, bridge_client(id), request, what: "the turn was not cancelled", cant: "cancel turns") do |reply|
|
|
1966
|
-
case reply.status
|
|
1967
|
-
when 202
|
|
1968
|
-
json_response(202, { status: "cancel_requested", session_id: id, reason: reply.json&.dig("reason"), via: "bridge" })
|
|
1969
|
-
# No active turn to cancel.
|
|
1970
|
-
when 409 then json_response(409, { error: "not_running", detail: "no active turn to cancel", session_id: id })
|
|
1971
|
-
end
|
|
1972
|
-
end
|
|
1973
|
-
end
|
|
1974
|
-
|
|
1975
|
-
# A task id as TaskRuntime.generate_task_id makes it.
|
|
1976
|
-
TASK_ID = /\A\d{14}-\h{8}\z/
|
|
1977
|
-
|
|
1978
|
-
# The stop-task button: the worker stops the task as the user
|
|
1979
|
-
# (Bridge#handle_task_stop) and its task_wait returns. Its own 404
|
|
1980
|
-
# (not this conversation's task) and 409 (already ended) pass through;
|
|
1981
|
-
# a 404 from a worker without the route is the stale worker's 501.
|
|
1982
|
-
def handle_task_stop(_req, id, task_id)
|
|
1983
|
-
return error_response(400, "invalid_task_id", "not a task id: #{task_id.to_s[0, 80]}") unless TASK_ID.match?(task_id)
|
|
1984
|
-
|
|
1985
|
-
@session_class.load(id, state_dir: default_state_dir)
|
|
1986
|
-
request = ->(client) { client.stop_task(task_id) }
|
|
1987
|
-
relay(id, bridge_client(id), request, what: "the task was not stopped", cant: "stop tasks") do |reply|
|
|
1988
|
-
body = reply.json || {}
|
|
1989
|
-
case reply.status
|
|
1990
|
-
when 200 then json_response(200, { status: body["status"], stop_reason: body["stop_reason"], task_id: task_id, session_id: id })
|
|
1991
|
-
when 404 then (error_response(404, "task_not_found", "no task #{task_id} in this conversation") if body["error"] == "task_not_found")
|
|
1992
|
-
when 409 then json_response(409, { error: "not_running", status: body["status"], task_id: task_id, session_id: id })
|
|
1993
|
-
end
|
|
1994
|
-
end
|
|
1995
|
-
end
|
|
1996
|
-
|
|
1997
|
-
# POST /api/sessions/:id/turn/:enqueued_id/dequeue {client_id?}: the ×
|
|
1998
|
-
# on a queued bubble takes the message back before it runs
|
|
1999
|
-
# (Bridge#handle_dequeue_turn). Straight to the live worker, never
|
|
2000
|
-
# through deliver_turn: a removal must not wake one, and a stopped
|
|
2001
|
-
# worker's queued file is the next worker's (503 not_live). 200
|
|
2002
|
-
# {status, enqueued_id, prompt}; 409 already_started / not_queued and
|
|
2003
|
-
# 404 unknown_enqueued_id pass through; the rest as every relay.
|
|
2004
|
-
def handle_dequeue_turn(req, id, enqueued_id)
|
|
2005
|
-
@session_class.load(id, state_dir: default_state_dir)
|
|
2006
|
-
parsed = parse_json(request_body(req))
|
|
2007
|
-
client_id = (parsed["client_id"] if parsed.is_a?(Hash))
|
|
2008
|
-
# A participant takes back only their own queued turns, by chi web's
|
|
2009
|
-
# record of who sent each (never a body's client id).
|
|
2010
|
-
unless (who = principal(req)).owner?
|
|
2011
|
-
return not_permitted_for(req, who) unless own_turn?(who.grant, id, enqueued_id)
|
|
2012
|
-
|
|
2013
|
-
client_id = ClientId.review(who.grant.id)
|
|
2014
|
-
end
|
|
2015
|
-
|
|
2016
|
-
request = ->(client) { client.dequeue(enqueued_id: enqueued_id, client_id: client_id) }
|
|
2017
|
-
relay(id, bridge_client(id), request, what: "the message was not removed", cant: "remove queued messages") do |reply|
|
|
2018
|
-
body = reply.json || {}
|
|
2019
|
-
case reply.status
|
|
2020
|
-
when 200 then json_response(200, { status: body["status"], enqueued_id: enqueued_id, prompt: body["prompt"].to_s, session_id: id })
|
|
2021
|
-
when 409 then json_response(409, { error: body["error"] || "already_started", detail: body["detail"], enqueued_id: enqueued_id, session_id: id }.compact)
|
|
2022
|
-
when 404
|
|
2023
|
-
json_response(404, { error: "unknown_enqueued_id", enqueued_id: enqueued_id, session_id: id }) if body["error"] == "unknown_enqueued_id"
|
|
2024
|
-
end
|
|
2025
|
-
end
|
|
2026
|
-
end
|
|
2027
|
-
|
|
2028
|
-
# POST /api/sessions/:id/stop {wait?}: 200 {status: "stopped",
|
|
2029
|
-
# session_id, released}: released, whether the worker let go of the
|
|
2030
|
-
# session within the wait (STOP_WAIT_SECONDS, or the body's seconds up
|
|
2031
|
-
# to SessionManager::STOP_WAIT: chi sessions stop's two wordings); 409
|
|
2032
|
-
# owned_by_tui.
|
|
2033
|
-
def handle_stop(req, id)
|
|
2034
|
-
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
2035
|
-
# after it just means an immediate resume finds it (rare).
|
|
2036
|
-
released = @manager.stop_session(id, state_dir: @state_dir, wait: stop_wait(req))
|
|
2037
|
-
@hub&.touch(id)
|
|
2038
|
-
json_response(200, { status: "stopped", session_id: id, released: released == true })
|
|
2039
|
-
rescue SessionManager::OwnedByTUI => e
|
|
2040
|
-
owned_by_tui(e)
|
|
2041
|
-
end
|
|
2042
|
-
|
|
2043
|
-
# The body's wait (a number of seconds), capped at
|
|
2044
|
-
# SessionManager::STOP_WAIT; STOP_WAIT_SECONDS without one.
|
|
2045
|
-
def stop_wait(req)
|
|
2046
|
-
body = parse_json(request_body(req, 4096))
|
|
2047
|
-
wait = body["wait"] if body.is_a?(Hash)
|
|
2048
|
-
wait.is_a?(Numeric) && wait.positive? ? [wait, SessionManager::STOP_WAIT].min : STOP_WAIT_SECONDS
|
|
2049
|
-
end
|
|
2050
|
-
|
|
2051
|
-
# POST /api/sessions/:id/restart: hand the session to a new worker on
|
|
2052
|
-
# the newest chi installed (SessionManager.restart_session). 200
|
|
2053
|
-
# {status: "restarted", session_id, from_version, version} (version nil:
|
|
2054
|
-
# the new worker is still starting; its bridge_up follows); 409
|
|
2055
|
-
# {error: "held" | "not_running" | "unsupported" | "failed", reason, detail} in words; 409
|
|
2056
|
-
# owned_by_tui; 404.
|
|
2057
|
-
def handle_restart(_req, id)
|
|
2058
|
-
result = @manager.restart_session(id, state_dir: @state_dir, client_id: ClientId::WEB_RESTART)
|
|
2059
|
-
@hub&.touch(id)
|
|
2060
|
-
json_response(200, { status: "restarted", session_id: id, from_version: result.from_version,
|
|
2061
|
-
version: result.version })
|
|
2062
|
-
rescue SessionManager::RestartRefused => e
|
|
2063
|
-
error = %i[not_running unsupported failed].include?(e.reason) ? e.reason.to_s : "held"
|
|
2064
|
-
refusal_response(409, error, e, reason: e.reason.to_s, session_id: id, version: e.version)
|
|
2065
|
-
rescue SessionManager::OwnedByTUI => e
|
|
2066
|
-
owned_by_tui(e)
|
|
2067
|
-
end
|
|
2068
|
-
|
|
2069
|
-
# POST /api/sessions/:id/archive: hide it (and its delegates) from the
|
|
2070
|
-
# lists, keep it for good. A live idle worker is stopped first; a turn
|
|
2071
|
-
# running in it or a delegate, a chi REPL's session and a scratch one
|
|
2072
|
-
# are refused (409).
|
|
2073
|
-
def handle_archive(_req, id)
|
|
2074
|
-
result = @manager.archive_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
|
|
2075
|
-
(result[:archived] + result[:discarded]).each { |sid| @hub&.touch(sid) }
|
|
2076
|
-
json_response(200, { status: "archived", session_id: result[:id], archived: result[:archived],
|
|
2077
|
-
stopped: result[:stopped], discarded: result[:discarded] })
|
|
2078
|
-
rescue SessionManager::OwnedByTUI => e
|
|
2079
|
-
owned_by_repl(id, e)
|
|
2080
|
-
rescue SessionManager::ArchiveRefused => e
|
|
2081
|
-
archive_refused(e)
|
|
2082
|
-
end
|
|
2083
|
-
|
|
2084
|
-
# POST /api/sessions/:id/unarchive: back to the lists, delegates too.
|
|
2085
|
-
def handle_unarchive(_req, id)
|
|
2086
|
-
result = @manager.unarchive_session(id, state_dir: @state_dir)
|
|
2087
|
-
result[:unarchived].each { |sid| @hub&.touch(sid) }
|
|
2088
|
-
json_response(200, { status: "unarchived", session_id: result[:id], unarchived: result[:unarchived] })
|
|
2089
|
-
end
|
|
2090
|
-
|
|
2091
|
-
# DELETE /api/sessions/:id?force=true: the session for good. With
|
|
2092
|
-
# force a live worker is stopped first (the page sends it: its confirm
|
|
2093
|
-
# says so); without it a live worker's session is refused, 409
|
|
2094
|
-
# worker_running, as chi sessions delete without --force. A chi REPL's
|
|
2095
|
-
# session is always refused.
|
|
2096
|
-
def handle_delete(req, id)
|
|
2097
|
-
result = @manager.delete_session(id, state_dir: @state_dir, stop: truthy_param?(req.params["force"]),
|
|
2098
|
-
wait: STOP_WAIT_SECONDS)
|
|
2099
|
-
@hub&.touch(result[:id])
|
|
2100
|
-
json_response(200, { status: "deleted", session_id: result[:id], stopped: result[:stopped] })
|
|
2101
|
-
rescue SessionManager::OwnedByTUI => e
|
|
2102
|
-
owned_by_repl(id, e)
|
|
2103
|
-
rescue SessionManager::DeleteRefused => e
|
|
2104
|
-
detail = e.reason == :still_stopping ? "#{e.message}; try again in a moment" : e.message
|
|
2105
|
-
refusal_response(409, e.reason.to_s, e, detail: detail, session_id: e.session_id, reason: e.reason.to_s)
|
|
2106
|
-
end
|
|
2107
|
-
|
|
2108
1060
|
# ── The working set (web.working_set) ───────────────────────────────
|
|
2109
1061
|
|
|
2110
1062
|
# GET /api/sessions/:id/working_set: the working set as the model has
|
|
@@ -2538,6 +1490,8 @@ module Samagotchi
|
|
|
2538
1490
|
when ".css" then "text/css; charset=utf-8"
|
|
2539
1491
|
when ".json" then "application/json; charset=utf-8"
|
|
2540
1492
|
when ".svg" then "image/svg+xml"
|
|
1493
|
+
when ".png" then "image/png"
|
|
1494
|
+
when ".webmanifest" then "application/manifest+json"
|
|
2541
1495
|
else "application/octet-stream"
|
|
2542
1496
|
end
|
|
2543
1497
|
end
|
|
@@ -2554,310 +1508,6 @@ module Samagotchi
|
|
|
2554
1508
|
registry: host_registry)
|
|
2555
1509
|
end
|
|
2556
1510
|
|
|
2557
|
-
# The whole timing: analytics.json's records merged with a live
|
|
2558
|
-
# worker's (by id), and turn_count, the finished turns (#turn_count).
|
|
2559
|
-
# +persisted+: the session's analytics.json when the caller read it.
|
|
2560
|
-
def timing_payload(session_id, live_metrics: nil, persisted: nil)
|
|
2561
|
-
persisted ||= read_analytics(session_id)
|
|
2562
|
-
live = live_metrics.is_a?(Hash)
|
|
2563
|
-
source = if live
|
|
2564
|
-
persisted.merge(live_metrics).merge(
|
|
2565
|
-
"started_at" => persisted["started_at"] || live_metrics["started_at"],
|
|
2566
|
-
"turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
|
|
2567
|
-
live_metrics["turn_records"]),
|
|
2568
|
-
"tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
|
|
2569
|
-
)
|
|
2570
|
-
else
|
|
2571
|
-
persisted
|
|
2572
|
-
end
|
|
2573
|
-
turns = Array(source["turn_records"])
|
|
2574
|
-
timing_from(source, turns, Array(source["tool_records"]), live: live,
|
|
2575
|
-
turn_count: (live && turn_count(live_metrics)) || turns.size)
|
|
2576
|
-
end
|
|
2577
|
-
|
|
2578
|
-
# The timing a ?tail=1 read sends: a live worker's recent
|
|
2579
|
-
# records as its metrics hold them (the newest finished turn's and
|
|
2580
|
-
# the unsaved ones, the running turn's finished calls), no
|
|
2581
|
-
# analytics.json read; with no worker the disk's, trimmed to the
|
|
2582
|
-
# newest turn. turn_count tells the page whether its merge missed one
|
|
2583
|
-
# (it then reads ?timing=1).
|
|
2584
|
-
def recent_timing(session_id, live_metrics)
|
|
2585
|
-
if live_metrics.is_a?(Hash)
|
|
2586
|
-
count = turn_count(live_metrics)
|
|
2587
|
-
# A metrics hash without the count: the whole timing.
|
|
2588
|
-
return timing_payload(session_id, live_metrics: live_metrics) unless count
|
|
2589
|
-
|
|
2590
|
-
turns = Array(live_metrics["turn_records"])
|
|
2591
|
-
tools = Array(live_metrics["tool_records"])
|
|
2592
|
-
# An older worker's metrics hold every record (before they held the
|
|
2593
|
-
# recent ones only, never more than LIVE_RECORDS_CAP turns).
|
|
2594
|
-
turns, tools = newest_turn_records(turns, tools, live_metrics["active_turn"]) if turns.size > SessionMetrics::LIVE_RECORDS_CAP
|
|
2595
|
-
return timing_from(live_metrics, turns, tools, live: true, turn_count: count)
|
|
2596
|
-
end
|
|
2597
|
-
|
|
2598
|
-
persisted = read_analytics(session_id)
|
|
2599
|
-
all = Array(persisted["turn_records"])
|
|
2600
|
-
turns, tools = newest_turn_records(all, Array(persisted["tool_records"]), nil)
|
|
2601
|
-
timing_from(persisted, turns, tools, live: false, turn_count: all.size)
|
|
2602
|
-
end
|
|
2603
|
-
|
|
2604
|
-
# The finished turns a live worker counts: its metrics' turns (the
|
|
2605
|
-
# loaded history, its own and the running one) less the running one.
|
|
2606
|
-
# @return [Integer, nil]
|
|
2607
|
-
def turn_count(metrics)
|
|
2608
|
-
turns = metrics["turns"]
|
|
2609
|
-
return nil unless turns.is_a?(Integer)
|
|
2610
|
-
|
|
2611
|
-
[turns - (metrics["active_turn"] ? 1 : 0), 0].max
|
|
2612
|
-
end
|
|
2613
|
-
|
|
2614
|
-
# The newest turn record and its tool records (and the running turn's
|
|
2615
|
-
# finished calls).
|
|
2616
|
-
def newest_turn_records(turns, tools, active_turn)
|
|
2617
|
-
kept = turns.last(1)
|
|
2618
|
-
ids = kept.filter_map { |r| r["id"] if r.is_a?(Hash) }
|
|
2619
|
-
ids << active_turn["id"] if active_turn.is_a?(Hash) && active_turn["id"]
|
|
2620
|
-
[kept, tools.select { |r| r.is_a?(Hash) && ids.include?(r["turn_id"]) }]
|
|
2621
|
-
end
|
|
2622
|
-
|
|
2623
|
-
def timing_from(source, turn_records, tool_records, live:, turn_count:)
|
|
2624
|
-
started_at = source["started_at"]
|
|
2625
|
-
last_activity_at = source["last_activity_at"]
|
|
2626
|
-
{
|
|
2627
|
-
started_at: started_at,
|
|
2628
|
-
last_activity_at: last_activity_at,
|
|
2629
|
-
session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live),
|
|
2630
|
-
turn_records: turn_records,
|
|
2631
|
-
tool_records: tool_records,
|
|
2632
|
-
turn_count: turn_count,
|
|
2633
|
-
active_turn: source["active_turn"],
|
|
2634
|
-
active_tools: Array(source["active_tools"]),
|
|
2635
|
-
# The context last seen and the token sums, for the ctx meter
|
|
2636
|
-
# before the next turn streams (live over saved).
|
|
2637
|
-
context: source["context"],
|
|
2638
|
-
tokens: source["tokens"],
|
|
2639
|
-
# The tokens, cost and speeds per model (SessionMetrics'
|
|
2640
|
-
# model_segments; none with one model, or from an older file).
|
|
2641
|
-
model_segments: Array(source["model_segments"]),
|
|
2642
|
-
# The memory indexes the session's prompt holds (the ctx tooltip).
|
|
2643
|
-
memory_index: source["memory_index"],
|
|
2644
|
-
# The side requests (recap, /btw, ctx.ask_model), apart from the
|
|
2645
|
-
# tokens: the ctx tooltip's own line (nil without any).
|
|
2646
|
-
side: source["side"]
|
|
2647
|
-
}
|
|
2648
|
-
end
|
|
2649
|
-
|
|
2650
|
-
def read_analytics(session_id)
|
|
2651
|
-
AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
|
|
2652
|
-
end
|
|
2653
|
-
|
|
2654
|
-
# The saved turn records without the one the live worker is running:
|
|
2655
|
-
# its turn checkpoint saves that turn's record so far as a killed
|
|
2656
|
-
# turn's (canceled, worker_stopped, SessionMetrics#persist's
|
|
2657
|
-
# running_turn), which is only true once the worker is gone.
|
|
2658
|
-
def without_running_turn(records, live_metrics)
|
|
2659
|
-
active = live_metrics["active_turn"]
|
|
2660
|
-
id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
|
|
2661
|
-
return records if id.nil?
|
|
2662
|
-
|
|
2663
|
-
Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
|
|
2664
|
-
end
|
|
2665
|
-
|
|
2666
|
-
def merge_timing_records(persisted, live)
|
|
2667
|
-
(Array(persisted) + Array(live)).each_with_object({}) do |record, records|
|
|
2668
|
-
next unless record.is_a?(Hash)
|
|
2669
|
-
|
|
2670
|
-
id = record["id"] || record[:id]
|
|
2671
|
-
records[id] = record if id
|
|
2672
|
-
end.values
|
|
2673
|
-
end
|
|
2674
|
-
|
|
2675
|
-
def session_duration_ms(started_at, last_activity_at, active:)
|
|
2676
|
-
started = Time.iso8601(started_at.to_s)
|
|
2677
|
-
finished = active ? Time.now : Time.iso8601(last_activity_at.to_s)
|
|
2678
|
-
[((finished - started) * 1000).round, 0].max
|
|
2679
|
-
rescue ArgumentError
|
|
2680
|
-
nil
|
|
2681
|
-
end
|
|
2682
|
-
|
|
2683
|
-
# The recap saved with the session and how many user turns came since:
|
|
2684
|
-
# a live worker's (it counts against its Engine's messages), else
|
|
2685
|
-
# recap.json against the session file. A stopped session gets its
|
|
2686
|
-
# recap without a worker being woken.
|
|
2687
|
-
# @return [Hash, nil] {text:, turns_since:, created_at:} (created_at:
|
|
2688
|
-
# when it was written, UTC ISO; absent from an older recap)
|
|
2689
|
-
def saved_recap_for(id, session, turn_snapshot)
|
|
2690
|
-
if turn_snapshot
|
|
2691
|
-
saved = turn_snapshot["saved_recap"]
|
|
2692
|
-
return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, created_at: saved["created_at"] }.compact
|
|
2693
|
-
end
|
|
2694
|
-
|
|
2695
|
-
saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
|
|
2696
|
-
return nil unless saved
|
|
2697
|
-
|
|
2698
|
-
messages = Array(session.messages)
|
|
2699
|
-
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
|
|
2700
|
-
{ text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
|
|
2701
|
-
rescue StandardError
|
|
2702
|
-
nil
|
|
2703
|
-
end
|
|
2704
|
-
|
|
2705
|
-
# The step (iteration) that answered the steer or merged input at
|
|
2706
|
-
# +index+: one past the model messages between it and the turn's
|
|
2707
|
-
# prompt.
|
|
2708
|
-
def steer_step(list, index)
|
|
2709
|
-
list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.turn_prompt?(m) }
|
|
2710
|
-
.count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
|
|
2711
|
-
end
|
|
2712
|
-
|
|
2713
|
-
def read_history(id)
|
|
2714
|
-
raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
|
|
2715
|
-
raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
|
|
2716
|
-
rescue StandardError
|
|
2717
|
-
[]
|
|
2718
|
-
end
|
|
2719
|
-
|
|
2720
|
-
# @param msgs [Array<Hash>] a session's messages; symbol keys from disk,
|
|
2721
|
-
# string keys from a Bridge snapshot
|
|
2722
|
-
# @param parts [Boolean] the turn view's reload (?parts=1): each
|
|
2723
|
-
# assistant message also carries what it did (MessageParts: thinking,
|
|
2724
|
-
# tool calls with params and output), and a message with nothing to
|
|
2725
|
-
# show but that (a text-less step) is kept with empty content
|
|
2726
|
-
# @param cwd [String, nil] the session's working directory (the parts'
|
|
2727
|
-
# tool titles are relative to it)
|
|
2728
|
-
def messages_for_display(msgs, parts: false, cwd: nil)
|
|
2729
|
-
filtered = []
|
|
2730
|
-
list = Array(msgs)
|
|
2731
|
-
# The outputs' ✂ marks (LLM context edits), by id, for the parts.
|
|
2732
|
-
marks = parts ? MessageParts.edit_marks(list) : {}
|
|
2733
|
-
list.each_with_index do |m, index|
|
|
2734
|
-
role = (m[:role] || m["role"]).to_s
|
|
2735
|
-
content = (m[:content] || m["content"]).to_s
|
|
2736
|
-
if Samagotchi::ContextNote.note?(m)
|
|
2737
|
-
note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
|
|
2738
|
-
# A context wake turn's note starts that turn (Steer.turn_prompt?):
|
|
2739
|
-
# the page opens the turn with it and pairs it with its record.
|
|
2740
|
-
if Samagotchi::Steer.turn_prompt?(m)
|
|
2741
|
-
note[:turn_start] = true
|
|
2742
|
-
note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
|
|
2743
|
-
end
|
|
2744
|
-
filtered << note
|
|
2745
|
-
next
|
|
2746
|
-
end
|
|
2747
|
-
# A plugin's steer: a row of the step that answered it, which the
|
|
2748
|
-
# page finds by `step`.
|
|
2749
|
-
if Samagotchi::Steer.steer?(m)
|
|
2750
|
-
filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index), **time_of(m) }
|
|
2751
|
-
next
|
|
2752
|
-
end
|
|
2753
|
-
# A turn that ended with no answer (TurnNote.empty's marker): its
|
|
2754
|
-
# empty steps (with ?parts=1) and the item the page draws its
|
|
2755
|
-
# notice from; the note itself stays hidden.
|
|
2756
|
-
if (marker = Samagotchi::TurnNote.empty_answer(m))
|
|
2757
|
-
filtered.concat(empty_answer_items(marker, parts: parts, cwd: cwd))
|
|
2758
|
-
next
|
|
2759
|
-
end
|
|
2760
|
-
next if role == "system"
|
|
2761
|
-
next if role == "tool_response"
|
|
2762
|
-
|
|
2763
|
-
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
2764
|
-
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
|
|
2765
|
-
next if stripped.empty? && did.nil?
|
|
2766
|
-
|
|
2767
|
-
norm_role = role == "model" ? "assistant" : role
|
|
2768
|
-
# normalize assistant vs model, keep user as is
|
|
2769
|
-
norm_role = "assistant" if %w[assistant model].include?(norm_role)
|
|
2770
|
-
norm_role = "user" if norm_role == "user"
|
|
2771
|
-
next unless %w[user assistant].include?(norm_role)
|
|
2772
|
-
|
|
2773
|
-
message = { role: norm_role, content: stripped, **time_of(m) }
|
|
2774
|
-
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
2775
|
-
# read by step +step+; a wake turn's report starts its turn
|
|
2776
|
-
# (Steer.turn_prompt?). Its sender (a delegate report, chi send):
|
|
2777
|
-
# the page's label.
|
|
2778
|
-
if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
2779
|
-
message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
|
|
2780
|
-
source = (m[:source] || m["source"]).to_s
|
|
2781
|
-
message[:source] = source unless source.empty?
|
|
2782
|
-
end
|
|
2783
|
-
# The turn it started (Engine): the page pairs it with that turn's
|
|
2784
|
-
# record by this, not by its place.
|
|
2785
|
-
turn_id = m[:turn_id] || m["turn_id"]
|
|
2786
|
-
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
2787
|
-
# A participant's message names them (the page's author badge).
|
|
2788
|
-
message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"])) if norm_role == "user"
|
|
2789
|
-
images = m[:images] || m["images"]
|
|
2790
|
-
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
2791
|
-
if norm_role == "assistant"
|
|
2792
|
-
# What an after_turn hook presented (AnswerDisplay): rendered and
|
|
2793
|
-
# copied in place of the answer, sanitised the same way; content
|
|
2794
|
-
# stays the model's text, which the page finds the bubble by.
|
|
2795
|
-
display = m[:display] || m["display"]
|
|
2796
|
-
shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
|
|
2797
|
-
message[:display] = shown unless shown.strip.empty?
|
|
2798
|
-
rendered = message[:display] || stripped
|
|
2799
|
-
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
2800
|
-
# The refs after_turn hooks attached (AnswerRefs), checked again:
|
|
2801
|
-
# a stored file is never trusted. Never raises.
|
|
2802
|
-
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
|
|
2803
|
-
message[:display_refs] = refs unless refs.empty?
|
|
2804
|
-
end
|
|
2805
|
-
message[:parts] = did if did
|
|
2806
|
-
filtered << message
|
|
2807
|
-
end
|
|
2808
|
-
filtered
|
|
2809
|
-
rescue StandardError
|
|
2810
|
-
[]
|
|
2811
|
-
end
|
|
2812
|
-
|
|
2813
|
-
# The entry's wall time (MessageTime: the page's hover and gap
|
|
2814
|
-
# dividers) as {at:}, or nothing for an entry saved without one.
|
|
2815
|
-
def time_of(message)
|
|
2816
|
-
at = Samagotchi::MessageTime.of(message)
|
|
2817
|
-
at.is_a?(String) && !at.empty? ? { at: at } : {}
|
|
2818
|
-
end
|
|
2819
|
-
|
|
2820
|
-
# A no-answer turn's marker as display items: each empty step that
|
|
2821
|
-
# has something to show (its thinking) as a text-less assistant step
|
|
2822
|
-
# (only with +parts+, as other text-less steps), then
|
|
2823
|
-
# { role: "empty_answer", retries: }.
|
|
2824
|
-
def empty_answer_items(marker, parts:, cwd:)
|
|
2825
|
-
steps = parts ? Array(marker[:steps] || marker["steps"]) : []
|
|
2826
|
-
items = steps.filter_map do |step|
|
|
2827
|
-
did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
|
|
2828
|
-
{ role: "assistant", content: "", parts: did } if did
|
|
2829
|
-
end
|
|
2830
|
-
refusal = marker[:refusal] || marker["refusal"]
|
|
2831
|
-
items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i, refusal: refusal }.compact
|
|
2832
|
-
end
|
|
2833
|
-
|
|
2834
|
-
# The last turn failed and its prompt went back to the user: the
|
|
2835
|
-
# prompt (session.last_prompt) and why, for the page to show after the
|
|
2836
|
-
# conversation as it did live. nil otherwise.
|
|
2837
|
-
def failed_turn_for(session, msgs)
|
|
2838
|
-
summary = Samagotchi::TurnNote.restored_failure(msgs)
|
|
2839
|
-
prompt = session.last_prompt.to_s
|
|
2840
|
-
return nil if summary.nil? || prompt.strip.empty?
|
|
2841
|
-
|
|
2842
|
-
{ prompt: prompt, summary: summary }
|
|
2843
|
-
end
|
|
2844
|
-
|
|
2845
|
-
# The parts of the assistant message at +index+, with the tool_response
|
|
2846
|
-
# messages right after it; +marks+: the session's ✂ marks
|
|
2847
|
-
# (MessageParts.edit_marks).
|
|
2848
|
-
def message_parts(list, index, cwd = nil, marks = {})
|
|
2849
|
-
responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
|
|
2850
|
-
MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
|
|
2851
|
-
end
|
|
2852
|
-
|
|
2853
|
-
# The last message messages_for_display shows as an answer
|
|
2854
|
-
# (AnswerTail), as a list of at most one: only that one is rendered.
|
|
2855
|
-
# +cwd+: the session's folder, for its file refs (nil from a live
|
|
2856
|
-
# worker's tail: its refs were held to the folder when they were added).
|
|
2857
|
-
def last_assistant_for_display(msgs, turn_id: nil, cwd: nil)
|
|
2858
|
-
messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact, cwd: cwd)
|
|
2859
|
-
end
|
|
2860
|
-
|
|
2861
1511
|
def parse_json(str)
|
|
2862
1512
|
return nil if str.nil? || str.strip.empty?
|
|
2863
1513
|
|