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
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../client_id"
4
+ require_relative "../guardrails/parent_policy"
5
+ require_relative "../llm_context_override"
6
+ require_relative "../model_profile"
7
+ require_relative "../parent_answers_command"
8
+ require_relative "../prompt_history"
9
+ require_relative "../review_start"
10
+ require_relative "../send_command"
11
+ require_relative "../session"
12
+ require_relative "../session_access"
13
+ require_relative "../session_chain"
14
+ require_relative "../session_config"
15
+ require_relative "../session_continue"
16
+ require_relative "../session_manager"
17
+ require_relative "../session_setup"
18
+ require_relative "../thinking"
19
+ require_relative "../toolkit"
20
+ require_relative "../turn_flow"
21
+
22
+ module Samagotchi
23
+ module Web
24
+ # POST /api/sessions: a new session (handle_create), a continue
25
+ # (handle_continue, continue_from), a review fork (request_review,
26
+ # handle_review_fork), and their refusals (continue_refusal,
27
+ # create_refusal, parent_gate, vision_refused) and request readers
28
+ # (request_toolkit, request_parent_answers, request_llm_context,
29
+ # request_thinking). Included in App: its ROUTES name handle_create, and
30
+ # its helpers (json_response, error_response, parse_json, request_body,
31
+ # await_bridge_port, session_to_json, record_history, scope_dir, …) and
32
+ # ivars (@manager, @session_class, @state_dir, @hub) answer. See
33
+ # docs/architecture.md (chi web's routes).
34
+ module SessionCreate
35
+ # What a review session's create refuses beside review.
36
+ REVIEW_REFUSES = %w[prompt preview idle continues].freeze
37
+
38
+ # What a continue takes from the previous link: refused beside it.
39
+ CONTINUE_TAKES = %w[dir llm_context thinking settings toolkit tools no_tools parent_answers].freeze
40
+
41
+ # chi send's client ids: its lines are checked as chi send checks them
42
+ # (#parent_gate).
43
+ CLI_SEND_IDS = [ClientId::CLI_SEND, ClientId::CLI_ANSWER].freeze
44
+
45
+ private
46
+
47
+ def handle_create(req)
48
+ body = parse_json(request_body(req))
49
+ unless body.is_a?(Hash)
50
+ return error_response(400, "invalid_json", "invalid JSON body")
51
+ end
52
+
53
+ prompt = body["prompt"] || body[:prompt]
54
+ # idle: a session with no first turn, for a first message with
55
+ # images (the page uploads them into it, then sends the turn).
56
+ idle = body["idle"] == true
57
+ review, error = request_review(body)
58
+ return error if error
59
+ return handle_review_fork(body, review) if review&.from
60
+
61
+ if prompt.to_s.strip.empty? && !idle && !review
62
+ return error_response(400, "missing_fields", "prompt is required")
63
+ end
64
+ return handle_continue(body, prompt, idle) if body.key?("continues")
65
+
66
+ # dir: the folder the chat starts in (the page's scope); without it,
67
+ # the server's own cwd.
68
+ dir, error = scope_dir(body["dir"])
69
+ return error if error
70
+
71
+ # llm_context: the session's own LLM context values before its first
72
+ # turn (the start page's llm ctx chip; chi --llm-context), read
73
+ # before anything spawns so a bad one spawns nothing.
74
+ llm_context, error = request_llm_context(body["llm_context"])
75
+ return error if error
76
+
77
+ # thinking: the session's own thinking level (the start page's think
78
+ # chip; chi --thinking), checked the same way.
79
+ thinking, error = request_thinking(body["thinking"])
80
+ return error if error
81
+
82
+ # settings: the session's own settings, flat as chi --set names
83
+ # them ({"recap.enabled" => false, "cost-cap.cap" => 3}), checked
84
+ # the same way (SessionConfig.from_request).
85
+ begin
86
+ settings = SessionConfig.from_request(body["settings"])
87
+ rescue SessionConfig::Error => e
88
+ return error_response(400, "invalid_settings", e.message)
89
+ end
90
+
91
+ # toolkit, tools, no_tools: its toolkit (chi send --new --toolkit,
92
+ # --tool, --no-tool: a name, tool names added and left out);
93
+ # parent_answers: what a parent agent may answer on its questions
94
+ # (hard, standard or soft; a looser one than config.yml's is capped,
95
+ # and the 201's parent_answers_warning says so).
96
+ toolkit, error = request_toolkit(body)
97
+ return error if error
98
+
99
+ parent_answers, error = request_parent_answers(body["parent_answers"])
100
+ return error if error
101
+
102
+ model = (body["model"] || body["model_name"]).to_s.strip
103
+ refused = create_refusal(body, idle ? body["preview"] : prompt, values: [parent_answers], model: model)
104
+ return refused if refused
105
+
106
+ folder = dir ? { working_directory: dir } : {}
107
+ # model: the model the session starts on (the start page's picker,
108
+ # spelled as GET /api/models lists it); blank means the default.
109
+ folder[:model_name] = model unless model.empty?
110
+ # preview: an idle session's first message, sent as its first turn
111
+ # next; the list and the notifications name the session by it
112
+ # until that turn is saved.
113
+ preview = idle ? body["preview"].to_s.strip : ""
114
+ folder[:preview] = preview unless preview.empty?
115
+ setup = SessionSetup.new(llm_context: llm_context, thinking: thinking, settings: settings, toolkit: toolkit,
116
+ parent_answers: parent_answers)
117
+ folder[:setup] = setup unless setup.empty?
118
+ begin
119
+ session = if review
120
+ ReviewStart.start(subjects: review.subjects, dir: dir || Dir.pwd, model: folder[:model_name],
121
+ setup: setup, state_dir: @state_dir)
122
+ else
123
+ @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
124
+ end
125
+ rescue ModelProfile::MissingModel => e
126
+ # No model configured, or one qualified with an unknown host.
127
+ return error_response(400, "invalid_model", e.message)
128
+ rescue ReviewStart::Refused => e
129
+ return error_response(400, "invalid_review", e.message)
130
+ end
131
+ # The worker's Bridge is the single live transport: wait (bounded) for
132
+ # it so the client can attach without client-side polling.
133
+ port = await_bridge_port(session.id)
134
+ # The projection holds it before the page hears back (no tick wait).
135
+ @hub&.touch(session.id)
136
+ record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
137
+ json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning,
138
+ parent_answers_warning: Guardrails::ParentPolicy.capped_note(parent_answers),
139
+ first_turn_id: session.first_turn_id))
140
+ end
141
+
142
+ # POST /api/sessions with continues: <id, prefix or last:id>: the next
143
+ # link of that session's chain (SessionManager.continue_session), in its
144
+ # folder and with its own setup (SessionSetup: llm_context, thinking
145
+ # and reasoning_history levels, settings), so dir, llm_context, thinking and settings are
146
+ # refused beside it (a create takes no reasoning_history). It runs on
147
+ # model (or model_name) when given, else on the previous link's: one
148
+ # whose host is gone falls back to the default, and the 201's
149
+ # model_warning (null when none) says so. Its opener is the text the
150
+ # page prefills (SessionChain.opener_for; null: the page's constant).
151
+ # A model it can't start on is 400 invalid_model, nothing archived.
152
+ # 409 continued (with next_id, the link that continues it already: the
153
+ # page opens that one), open_children (ids), folder_gone, busy, scratch
154
+ # or owned_by_tui.
155
+ def handle_continue(body, prompt, idle)
156
+ given = body["continues"]
157
+ unless given.is_a?(String) && !given.strip.empty?
158
+ return error_response(400, "invalid_continues", "continues is a session id")
159
+ end
160
+ if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
161
+ return error_response(400, "invalid_continues",
162
+ "continues takes the previous session's folder, llm_context, thinking, toolkit, " \
163
+ "settings and parent_answers; leave out #{fields.join(", ")}")
164
+ end
165
+
166
+ preview = idle ? body["preview"].to_s.strip : ""
167
+ model = (body["model"] || body["model_name"]).to_s.strip
168
+ refused = continue_refusal(given, body, idle ? body["preview"] : prompt, model: model)
169
+ return refused if refused
170
+
171
+ session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, preview: preview.empty? ? nil : preview,
172
+ model: model.empty? ? nil : model)
173
+ return error if error
174
+
175
+ port = await_bridge_port(session.id)
176
+ moved = session.moved_children || []
177
+ # The moved delegates' cards fold under the new link now.
178
+ [session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
179
+ record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
180
+ json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
181
+ model_warning: session.model_warning,
182
+ opener: session.opener,
183
+ first_turn_id: session.first_turn_id))
184
+ end
185
+
186
+ # The create's review: {subjects: [paths], from: id} (chi review, the
187
+ # page's Start review) as a SessionAccess::Review: [review, nil] (nil
188
+ # when absent), or [nil, a 400 invalid_review]. A review session
189
+ # starts idle: prompt, preview, idle and continues are refused beside
190
+ # it. Its subjects are checked where it starts (ReviewStart).
191
+ def request_review(body)
192
+ return [nil, nil] unless body.key?("review")
193
+
194
+ raw = body["review"]
195
+ subjects = raw.is_a?(Hash) ? raw.fetch("subjects", []) : nil
196
+ from = raw.is_a?(Hash) ? raw["from"] : nil
197
+ unless subjects.is_a?(Array) && subjects.all?(String) && (from.nil? || (from.is_a?(String) && !from.strip.empty?))
198
+ return [nil, error_response(400, "invalid_review", "review is {subjects: [project files], from: a session id}")]
199
+ end
200
+ if (fields = REVIEW_REFUSES.select { |key| body.key?(key) }).any?
201
+ return [nil, error_response(400, "invalid_review", "a review session starts idle; leave out #{fields.join(", ")}")]
202
+ end
203
+
204
+ [SessionAccess::Review.new(subjects: subjects, from: from&.strip), nil]
205
+ end
206
+
207
+ # POST /api/sessions with review.from: a review fork of that session
208
+ # (ReviewStart.fork), in its folder and with its setup (dir and the
209
+ # setup fields refused beside it, as for a continue), on model when
210
+ # given. 201 as a create's; 400 invalid_review (a subject, its folder
211
+ # gone), invalid_model, ambiguous_id; 404 not_found.
212
+ def handle_review_fork(body, review)
213
+ if (fields = CONTINUE_TAKES.select { |key| body.key?(key) }).any?
214
+ return error_response(400, "invalid_review",
215
+ "a review of a session takes its folder and setup; leave out #{fields.join(", ")}")
216
+ end
217
+
218
+ model = (body["model"] || body["model_name"]).to_s.strip
219
+ begin
220
+ session = ReviewStart.fork(from: review.from, subjects: review.subjects, model: model.empty? ? nil : model,
221
+ state_dir: @state_dir)
222
+ rescue ReviewStart::Refused => e
223
+ return error_response(400, "invalid_review", e.message)
224
+ rescue ModelProfile::MissingModel => e
225
+ return error_response(400, "invalid_model", e.message)
226
+ rescue Session::AmbiguousId => e
227
+ return refusal_response(400, "ambiguous_id", e, ids: e.ids)
228
+ rescue ArgumentError => e
229
+ return error_response(404, "not_found", e.message)
230
+ end
231
+ port = await_bridge_port(session.id)
232
+ @hub&.touch(session.id)
233
+ json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning))
234
+ end
235
+
236
+ # The previous link's parent_answers and the model the new one runs,
237
+ # for #create_refusal; nil when the previous link isn't there
238
+ # (continue_from says so).
239
+ def continue_refusal(given, body, line, model:)
240
+ return nil unless body["check_vision"] == true || CLI_SEND_IDS.include?(body["client_id"])
241
+
242
+ previous = Session.load(SessionChain.resolve(given, state_dir: default_state_dir), state_dir: default_state_dir)
243
+ create_refusal(body, line, values: [previous.parent_answers], model: model, previous: previous)
244
+ rescue ArgumentError, ModelProfile::MissingModel
245
+ nil
246
+ end
247
+
248
+ # A create refused before anything spawns, as chi send --new refuses
249
+ # it (SendCommand::ParentGate, the vision check): its first line
250
+ # (+line+, the prompt or an idle one's preview) when it is a parent
251
+ # agent's /continue yes or a /parent-answers that loosens +values+
252
+ # (#parent_gate), and with check_vision: true (its first message
253
+ # brings images) a model known not to take them (422 no_vision).
254
+ # @param previous [Session, nil] a continue's previous link: its model
255
+ # decides when +model+ is blank
256
+ # @return [Array, nil] the response, or nil to go on
257
+ def create_refusal(body, line, values:, model:, previous: nil)
258
+ refused = parent_gate(line, client_id: body["client_id"], values: values)
259
+ return refused if refused
260
+ return nil unless body["check_vision"] == true
261
+
262
+ name, typed = if previous
263
+ SendCommand.continued_model(previous, model)
264
+ else
265
+ [model.empty? ? nil : model, nil]
266
+ end
267
+ vision_refused(name, typed)
268
+ end
269
+
270
+ # A line from chi send (client_id cli:send, or cli:answer for a parent
271
+ # agent's /continue yes) refused here as chi send refuses it on its own
272
+ # machine (SendCommand::ParentGate), held to this server's config.yml:
273
+ # a parent's /continue yes that guardrails.parent_answers (tightened by
274
+ # +values+, the targets' own parent_answers) refuses, 403
275
+ # parent_continue_refused; a /parent-answers line that loosens one of
276
+ # +values+, 403 parent_answers_refused (loosening is the user's at a
277
+ # UI). The page's lines (web:…) and others aren't checked here; the
278
+ # worker checks every line again.
279
+ # @return [Array, nil] the response, or nil to go on
280
+ def parent_gate(line, client_id:, values:)
281
+ line = line.to_s
282
+ return nil unless CLI_SEND_IDS.include?(client_id)
283
+
284
+ if client_id == ClientId::CLI_ANSWER && TurnFlow.continue_yes?(line) &&
285
+ (refusal = SendCommand::ParentGate.continue_refusal_for(values))
286
+ return error_response(403, "parent_continue_refused", refusal)
287
+ end
288
+ return nil unless SendCommand::ParentGate.loosens_any?(line, values)
289
+
290
+ error_response(403, "parent_answers_refused", ParentAnswersCommand::REFUSED)
291
+ end
292
+
293
+ # 422 no_vision in chi send's words when the model (+name+, nil: the
294
+ # default) is known not to take images; nil when it does or is
295
+ # unknown (the worker decides when the turn runs).
296
+ def vision_refused(name, typed)
297
+ refusal = SendCommand.vision_refusal(name, SendCommand.vision_answer(name, typed: typed))
298
+ refusal && error_response(422, "no_vision", refusal)
299
+ end
300
+
301
+ # The create request's toolkit (a name), tools and no_tools (names, a
302
+ # comma list each too), as chi send --new's --toolkit, --tool and
303
+ # --no-tool: [toolkit, nil] (nil for none), or [nil, a 400 response].
304
+ def request_toolkit(body)
305
+ name = body["toolkit"]
306
+ lists = %w[tools no_tools].to_h { |key| [key, body[key]] }
307
+ unless (name.nil? || name.is_a?(String)) &&
308
+ lists.values.all? { |list| list.nil? || (list.is_a?(Array) && list.all?(String)) }
309
+ return [nil, error_response(400, "invalid_toolkit", "toolkit is a name; tools and no_tools are lists of names")]
310
+ end
311
+
312
+ [Toolkit.from_flags(name: name, add: lists["tools"] || [], remove: lists["no_tools"] || []), nil]
313
+ end
314
+
315
+ # The create request's parent_answers (hard, standard or soft;
316
+ # "default" or none: config.yml's): [level, nil] or [nil, a 400].
317
+ def request_parent_answers(raw)
318
+ return [nil, nil] if raw.nil?
319
+
320
+ level = raw.is_a?(String) ? Guardrails::ParentPolicy.parse_level(raw) : :invalid
321
+ return [nil, nil] if level == :unset
322
+ unless Guardrails::ParentPolicy::ORDER.include?(level)
323
+ return [nil, error_response(400, "invalid_parent_answers",
324
+ "parent_answers takes #{Guardrails::ParentPolicy::ORDER.join(", ")}")]
325
+ end
326
+
327
+ [level, nil]
328
+ end
329
+
330
+ # SessionManager.continue_session, its refusals as responses: [session,
331
+ # nil] or [nil, the response]. Only its own errors: what fails after
332
+ # the new link started is not a refusal of it.
333
+ def continue_from(given, prompt:, preview:, model:)
334
+ [@manager.continue_session(given, prompt: prompt, preview: preview, model: model, state_dir: @state_dir), nil]
335
+ rescue SessionContinue::Refused => e
336
+ extra = e.reason == :continued ? { next_id: e.ids.first } : {}
337
+ [nil, refusal_response(409, e.reason.to_s, e, session_id: e.session_id, reason: e.reason.to_s, ids: e.ids, **extra)]
338
+ rescue SessionManager::ArchiveRefused => e
339
+ [nil, archive_refused(e)]
340
+ rescue SessionManager::OwnedByTUI => e
341
+ [nil, owned_by_repl(given, e)]
342
+ rescue ModelProfile::MissingModel => e
343
+ [nil, error_response(400, "invalid_model", e.message)]
344
+ rescue Session::AmbiguousId => e
345
+ [nil, refusal_response(400, "ambiguous_id", e, ids: e.ids)]
346
+ rescue ArgumentError => e
347
+ [nil, error_response(404, "not_found", e.message)]
348
+ end
349
+
350
+ # The create request's "llm_context" ({strategy:, apply:, budget:},
351
+ # each a word as /llm-context takes it, "default" for unset) as an
352
+ # override: [override, nil] (nil when absent or all default), or
353
+ # [nil, a 400 response] for anything else.
354
+ def request_llm_context(raw)
355
+ return [nil, nil] if raw.nil?
356
+
357
+ fields = LLMContextOverride::COMMAND_WORDS.invert
358
+ unless raw.is_a?(Hash) && raw.all? { |key, word| fields.key?(key) && word.is_a?(String) }
359
+ return [nil, error_response(400, "invalid_llm_context",
360
+ "llm_context is an object of #{fields.keys.join(", ")} words")]
361
+ end
362
+
363
+ override = LLMContextOverride.update(nil, raw.to_h { |key, word| [fields.fetch(key), word] })
364
+ [override.empty? ? nil : override, nil]
365
+ rescue ArgumentError => e
366
+ [nil, error_response(400, "invalid_llm_context", e.message)]
367
+ end
368
+
369
+ # The create request's "thinking" (a level word as /thinking takes it,
370
+ # "default" for none) as the session's own level: [level, nil] (nil
371
+ # when absent or default), or [nil, a 400 response] for anything else.
372
+ def request_thinking(raw)
373
+ return [nil, nil] if raw.nil?
374
+
375
+ word = raw.is_a?(String) ? raw.strip.downcase : nil
376
+ unless word && Thinking::LEVELS.map(&:to_s).include?(word)
377
+ return [nil, error_response(400, "invalid_thinking", "thinking is one of #{Thinking::LEVELS.join(", ")}")]
378
+ end
379
+
380
+ [Thinking.session_level(word), nil]
381
+ end
382
+ end
383
+ end
384
+ end