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