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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +43 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +3 -3
  9. data/docs/internals/web-push.md +200 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  19. data/lib/samagotchi/send_command.rb +2 -1
  20. data/lib/samagotchi/session.rb +12 -3
  21. data/lib/samagotchi/session_access/remote.rb +2 -1
  22. data/lib/samagotchi/session_access.rb +7 -3
  23. data/lib/samagotchi/session_manager.rb +5 -0
  24. data/lib/samagotchi/turn_endings.rb +137 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +27 -1377
  27. data/lib/samagotchi/web/grants.rb +3 -12
  28. data/lib/samagotchi/web/message_display.rb +274 -0
  29. data/lib/samagotchi/web/private_files.rb +31 -0
  30. data/lib/samagotchi/web/public/app.js +30 -6
  31. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  32. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  33. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  34. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  35. data/lib/samagotchi/web/public/index.html +4 -0
  36. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  37. data/lib/samagotchi/web/public/notifications.js +137 -3
  38. data/lib/samagotchi/web/public/notify.js +1 -1
  39. data/lib/samagotchi/web/public/push.js +95 -0
  40. data/lib/samagotchi/web/public/stage_view.js +8 -0
  41. data/lib/samagotchi/web/public/sw.js +101 -0
  42. data/lib/samagotchi/web/public/turn_events.js +22 -0
  43. data/lib/samagotchi/web/push/attention.rb +105 -0
  44. data/lib/samagotchi/web/push/client.rb +103 -0
  45. data/lib/samagotchi/web/push/crypto.rb +126 -0
  46. data/lib/samagotchi/web/push/notifier.rb +230 -0
  47. data/lib/samagotchi/web/push/store.rb +285 -0
  48. data/lib/samagotchi/web/push.rb +73 -0
  49. data/lib/samagotchi/web/push_routes.rb +99 -0
  50. data/lib/samagotchi/web/server.rb +26 -2
  51. data/lib/samagotchi/web/session_control.rb +441 -0
  52. data/lib/samagotchi/web/session_create.rb +384 -0
  53. data/lib/samagotchi/web/session_show.rb +411 -0
  54. data/lib/samagotchi/worker.rb +3 -2
  55. metadata +25 -1
@@ -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
- GATE_FREE_PATHS = ["/token_fragment.js"].freeze
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