samagotchi 0.110.0 → 0.111.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +44 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +8 -4
  9. data/docs/internals/web-push.md +210 -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/reply_wait/poll.rb +11 -10
  19. data/lib/samagotchi/reply_wait.rb +30 -3
  20. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  21. data/lib/samagotchi/send_command.rb +2 -1
  22. data/lib/samagotchi/session.rb +12 -3
  23. data/lib/samagotchi/session_access/remote.rb +7 -2
  24. data/lib/samagotchi/session_access.rb +7 -3
  25. data/lib/samagotchi/session_manager.rb +5 -0
  26. data/lib/samagotchi/turn_endings.rb +137 -0
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/app.rb +27 -1377
  29. data/lib/samagotchi/web/grants.rb +3 -12
  30. data/lib/samagotchi/web/message_display.rb +274 -0
  31. data/lib/samagotchi/web/participant_access.rb +1 -1
  32. data/lib/samagotchi/web/private_files.rb +31 -0
  33. data/lib/samagotchi/web/public/app.js +30 -6
  34. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  35. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  36. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  37. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  38. data/lib/samagotchi/web/public/index.html +4 -0
  39. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  40. data/lib/samagotchi/web/public/notifications.js +178 -3
  41. data/lib/samagotchi/web/public/notify.js +1 -1
  42. data/lib/samagotchi/web/public/push.js +95 -0
  43. data/lib/samagotchi/web/public/stage_view.js +8 -0
  44. data/lib/samagotchi/web/public/sw.js +101 -0
  45. data/lib/samagotchi/web/public/turn_events.js +22 -0
  46. data/lib/samagotchi/web/push/attention.rb +105 -0
  47. data/lib/samagotchi/web/push/client.rb +103 -0
  48. data/lib/samagotchi/web/push/crypto.rb +126 -0
  49. data/lib/samagotchi/web/push/notifier.rb +230 -0
  50. data/lib/samagotchi/web/push/store.rb +295 -0
  51. data/lib/samagotchi/web/push.rb +73 -0
  52. data/lib/samagotchi/web/push_routes.rb +104 -0
  53. data/lib/samagotchi/web/server.rb +26 -2
  54. data/lib/samagotchi/web/session_access_routes.rb +19 -8
  55. data/lib/samagotchi/web/session_control.rb +441 -0
  56. data/lib/samagotchi/web/session_create.rb +384 -0
  57. data/lib/samagotchi/web/session_show.rb +411 -0
  58. data/lib/samagotchi/worker.rb +3 -2
  59. metadata +25 -1
@@ -63,8 +63,31 @@ module Samagotchi
63
63
  # @!attribute origin [String, nil] "command" for a :no_reply that a
64
64
  # file-delivered session command settled (no turn ran:
65
65
  # TurnCheckpoint.record_command_outcome); nil otherwise
66
+ # @!attribute running_look [RunningLook, nil] on a :timeout, the turn
67
+ # this wait saw running (call's running_look:, for a wait that goes
68
+ # on in a next call: chi web's 30 s slices of a remote wait)
66
69
  Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
67
- :stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
70
+ :stopped_by, :kept_steps, :session, :turn_id, :origin, :running_look, keyword_init: true)
71
+
72
+ # A turn a wait with no turn: saw running with its checkpoint mark:
73
+ # last_turn's ended_at then and the names of the input files claimed
74
+ # then (*.json.processing), so its settle by a next worker tells from
75
+ # a turn that ended or an input that runs next (Poll#settled_running).
76
+ # Plain strings, so a remote wait carries it from one slice to the next.
77
+ RunningLook = Data.define(:last_end, :claimed) do
78
+ # @return [RunningLook, nil] nil unless +raw+ is {last_end: String or
79
+ # nil, claimed: [file names]} (string keys, as JSON parsed it)
80
+ def self.from_h(raw)
81
+ return nil unless raw.is_a?(Hash) && raw.keys.sort == %w[claimed last_end]
82
+
83
+ last_end = raw["last_end"]
84
+ claimed = raw["claimed"]
85
+ return nil unless (last_end.nil? || last_end.is_a?(String)) && claimed.is_a?(Array) &&
86
+ claimed.all? { |name| name.is_a?(String) && File.basename(name) == name }
87
+
88
+ new(last_end: last_end, claimed: claimed.sort.freeze)
89
+ end
90
+ end
68
91
 
69
92
  # The session as it was before a message went in (call's baseline:,
70
93
  # #baseline_of): its message count, the question pending then, and when
@@ -133,13 +156,17 @@ module Samagotchi
133
156
  # sorts at the cursor, not past it, and its end precedes the
134
157
  # baseline, so without it the wait would time out "running" again.
135
158
  # Not for a turn: wait (chi answer) whose own end it waits for.
159
+ # @param running_look [RunningLook, nil] the running turn an earlier
160
+ # call of the same wait saw (its :timeout result's running_look): its
161
+ # settle ends this call too, as if this call had seen it running
136
162
  # @return [Result]
137
163
  # @raise [ArgumentError] no such session
138
164
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
139
- baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
165
+ baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false,
166
+ running_look: nil)
140
167
  Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
141
168
  cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
142
- queued: queued, ended_before: ended_before).run
169
+ queued: queued, ended_before: ended_before, running_look: running_look).run
143
170
  end
144
171
 
145
172
  # The session as it was before a message went in (call's baseline:):
@@ -19,11 +19,18 @@ module Samagotchi
19
19
  # settle end the wait (TurnCheckpoint.settled_turn). A message merged
20
20
  # into a turn starting then falls back to that turn's reply
21
21
  # (ReplyWait's turn: without queued:); no id (an older worker), the
22
- # next reply.
22
+ # next reply. A prompt the session was created with has no delivery:
23
+ # its turn is the id the create gave it (+direct_turn+,
24
+ # Session#first_turn_id), and nothing can merge into it, so the wait
25
+ # is for that turn alone (ReplyWait's queued:).
23
26
  # @param delivered [SessionManager::DeliverResult, nil] nil when the
24
27
  # message started the session itself (no delivery)
25
- def self.first_message(delivered)
26
- return new unless delivered
28
+ # @param direct_turn [String, nil] that first turn's id, for a direct
29
+ # start
30
+ def self.first_message(delivered, direct_turn: nil)
31
+ return new unless delivered || !direct_turn.to_s.strip.empty?
32
+
33
+ return new(turn: direct_turn, report_turn_id: direct_turn, queued: true) if delivered.nil?
27
34
 
28
35
  new(turn: delivered.enqueued_id, report_turn_id: delivered.enqueued_id)
29
36
  end
@@ -521,7 +521,8 @@ module Samagotchi
521
521
 
522
522
  start = @access.prepare_wait(session.id, session: session, created: true)
523
523
  wait_for_reply(session.id, cursor: start.cursor, baseline: start.baseline,
524
- timeout: options[:timeout], **WaitTarget.first_message(@delivered).to_h)
524
+ timeout: options[:timeout],
525
+ **WaitTarget.first_message(@delivered, direct_turn: session.first_turn_id).to_h)
525
526
  end
526
527
 
527
528
  # What chi send --new asks to create: through the worker (images, a
@@ -54,6 +54,13 @@ module Samagotchi
54
54
  attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
55
55
  :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
56
56
  :used_memory_names
57
+ # The id the prompt a session is created with gets (SessionManager
58
+ # .spawn_session), so its first turn is a turn with an id like one sent
59
+ # later: the worker's first turn takes it as its origin, its reply file
60
+ # and last_turn carry it, and a wait for it (`chi send --new --wait`,
61
+ # chi web's create) can name it (`--wait --turn T ID`). nil for a
62
+ # session created idle, and for one saved before the field existed.
63
+ attr_accessor :first_turn_id
57
64
  # Memories the session was started with (--memory) and memories hidden
58
65
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
59
66
  # Names as given; the engine normalizes them.
@@ -310,7 +317,7 @@ module Samagotchi
310
317
 
311
318
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
312
319
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
313
- first_preview: "", test_run: false, pending_question: nil,
320
+ first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
314
321
  used_memory_names: [], project_root: nil,
315
322
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
316
323
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
@@ -329,6 +336,7 @@ module Samagotchi
329
336
  @status = status
330
337
  @last_prompt = last_prompt
331
338
  @first_preview = first_preview
339
+ @first_turn_id = first_turn_id
332
340
  @title = title
333
341
  @test_run = !!test_run
334
342
  self.pending_question = pending_question
@@ -525,12 +533,13 @@ module Samagotchi
525
533
  "interrupted_turn" => nil,
526
534
  "parent_answers" => nil,
527
535
  "working_set_pins" => [],
528
- "review" => nil
536
+ "review" => nil,
537
+ "first_turn_id" => nil
529
538
  }.freeze
530
539
 
531
540
  # FIELDS a session file has only when set: a session without them saves
532
541
  # as it did before they existed.
533
- OPTIONAL_FIELDS = %w[review].freeze
542
+ OPTIONAL_FIELDS = %w[review first_turn_id].freeze
534
543
 
535
544
  # A session from a parsed session file. +messages+ false leaves the
536
545
  # conversation out (the lists' lightweight summaries).
@@ -179,7 +179,8 @@ module Samagotchi
179
179
 
180
180
  json = reply.json
181
181
  Created.new(id: json.fetch("id"), model_warning: json["model_warning"], continues: json["continues"],
182
- moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"])
182
+ moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"],
183
+ first_turn_id: json["first_turn_id"])
183
184
  end
184
185
 
185
186
  # Whether a just-created session's worker came up within +timeout+
@@ -332,7 +333,9 @@ module Samagotchi
332
333
  end
333
334
 
334
335
  # ReplyWait on the server, asked again each WAIT_SLICE until it ends
335
- # or +timeout+ passes.
336
+ # or +timeout+ passes. Each slice is a fresh ReplyWait there: the
337
+ # running turn a slice saw (its timeout's running_look) goes into the
338
+ # next, so a settle between two slices ends the wait as it would here.
336
339
  # @return [ReplyWait::Result]
337
340
  # @raise [ArgumentError] the session is gone
338
341
  def wait_reply(id, cursor:, timeout:, baseline:, owner_grace: nil, turn: nil, queued: false, ended_before: false,
@@ -355,6 +358,8 @@ module Samagotchi
355
358
  @strategies[id] = strategy_of(reply.json["parent_strategy"])
356
359
  result = result_of(reply.json)
357
360
  return result unless result.status == :timeout
361
+
362
+ body[:running_look] = reply.json["running_look"]
358
363
  end
359
364
  end
360
365
 
@@ -160,9 +160,13 @@ module Samagotchi
160
160
 
161
161
  # What chi send --new created (Remote; Local gives the Session): its
162
162
  # id, a model warning, the session it continues, the delegates it took
163
- # over, and the server's word on a --parent-answers looser than its
164
- # config.yml's (capped).
165
- Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning)
163
+ # over, the server's word on a --parent-answers looser than its
164
+ # config.yml's (capped), and the id the prompt it started with gets for
165
+ # its first turn (nil for one created idle).
166
+ Created = Data.define(:id, :model_warning, :continues, :moved_children, :parent_answers_warning, :first_turn_id) do
167
+ def initialize(id:, model_warning: nil, continues: nil, moved_children: [], parent_answers_warning: nil,
168
+ first_turn_id: nil) = super
169
+ end
166
170
 
167
171
  # A session's worker as an attach finds it (Local#worker, chi web's GET
168
172
  # …/worker): +sidecar+ the live worker's WorkerSidecar (port, version,
@@ -197,6 +197,11 @@ module Samagotchi
197
197
  # the prompts), so the session starts idle.
198
198
  session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
199
199
  session.last_prompt = prompt
200
+ # The first prompt's turn gets an id like a sent message's, so a wait
201
+ # for it can name it (chi send --new --wait, chi web's create): the
202
+ # worker runs it with that origin, and its reply file and last_turn
203
+ # carry the id.
204
+ session.first_turn_id = SecureRandom.uuid unless prompt.to_s.strip.empty?
200
205
  # The worker takes last_prompt and clears it, and messages are saved at
201
206
  # the turn's end: until then this is the only preview a list has.
202
207
  session.first_preview = Session.preview_of(preview.to_s.strip.empty? ? prompt : preview)
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm/errors"
4
+ require_relative "tools/task_runtime"
5
+ require_relative "turn_note"
6
+
7
+ module Samagotchi
8
+ # What each of an Engine's turn endings keeps and announces: pure builders
9
+ # over the turn, its result or its error. The kept messages and their
10
+ # TurnNote (#kept_messages, #ctrl_c_messages, #failed_messages), the end
11
+ # event (#failed_event), the turn summary and the stop facts. The Engine
12
+ # forwards each here and keeps the steps that hold the event lock or save
13
+ # (end_turn, complete_turn, store_answer_display, record_last_turn).
14
+ class TurnEndings
15
+ # @param failure_summary [#call] (error) → a failed turn's one line (Engine#failure_summary)
16
+ def initialize(failure_summary:)
17
+ @failure_summary = failure_summary
18
+ end
19
+
20
+ # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
21
+ # parent agent's wait (ReplyWait): a provider error's kind and whether
22
+ # a retry may help, a cancel's reason and the hook that stopped it.
23
+ # Only what is known.
24
+ def stop_facts(event)
25
+ case event&.dig(:type)
26
+ # kept_steps: the turn's work stayed (a parent must not send the task again).
27
+ when :turn_failed
28
+ { error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
29
+ when :turn_canceled
30
+ { cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
31
+ else {}
32
+ end
33
+ end
34
+
35
+ # What the session keeps of a turn that ended normally (nil: nothing to
36
+ # replace). Runs under the event lock: it also adds the turn's note to
37
+ # result.conversation, which the REPL keeps as-is.
38
+ # A turn that ran out of iterations ends at its tool results, so a
39
+ # continue resumes from them rather than after a made-up reply (and a
40
+ # step-limit Continue from its run's start: ModelResult#run_start).
41
+ def kept_messages(turn, result, seconds)
42
+ conversation = result.conversation if result.conversation.is_a?(Array)
43
+ # Nothing visible (no text) is a turn the model should know ended
44
+ # that way: the note, and nothing made up as its answer. The note's
45
+ # marker is for the UIs (the notice, and the empty steps after a
46
+ # reload); it goes on the result's conversation too (the REPL keeps it).
47
+ # A retry's nudge at the tail goes: this note says it all. An earlier
48
+ # turn's note stays: it says why that turn ended.
49
+ if result.empty_answer?
50
+ note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
51
+ conversation&.replace(TurnNote.without_trailing_nudge(conversation))
52
+ saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
53
+ conversation << note if conversation
54
+ saved + [note]
55
+ elsif result.canceled? && conversation
56
+ conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
57
+ stopped_by: turn.controller.detail,
58
+ shown: TurnNote.interrupted_tail?(conversation),
59
+ running_tasks: Tools::TaskRuntime.running_created_in(conversation))
60
+ else
61
+ conversation
62
+ end
63
+ end
64
+
65
+ # A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
66
+ # finished tool iterations, every call answered: ToolResponse.run_batch),
67
+ # else the turn's messages so far (it came before the loop), with a
68
+ # cancel note naming the tasks still running; nil before there are any.
69
+ def ctrl_c_messages(turn, interrupt, seconds)
70
+ kept = loop_conversation(interrupt) || turn.messages
71
+ return nil unless kept
72
+
73
+ note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
74
+ TurnNote.replace_trailing(kept, note)
75
+ end
76
+
77
+ # The conversation the loop had built when +error+ ended the turn
78
+ # (LLM::FailedTurn), or nil.
79
+ def loop_conversation(error)
80
+ error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
81
+ end
82
+
83
+ # A failure keeps the loop's partial conversation (else the turn's
84
+ # messages so far) with a failed note; nil when the turn never reached
85
+ # the model. The model reads why on its next turn. A turn that got to
86
+ # tool results (LLM::FailedTurn.progress) says so in the note; on the
87
+ # error (kept_steps) too unless it failed in a way that rolls back
88
+ # (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
89
+ # turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
90
+ # note of their own; -p --non-interactive keeps what this keeps.
91
+ def failed_messages(turn, error)
92
+ kept = loop_conversation(error) || turn.messages
93
+ return nil unless kept
94
+
95
+ steps = LLM::FailedTurn.progress(turn.session.messages, kept)
96
+ error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
97
+ summary = error.respond_to?(:summary) ? error.summary : error.message
98
+ TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
99
+ end
100
+
101
+ # Called after #failed_messages, which marks the error's kept steps.
102
+ def failed_event(error, seconds)
103
+ failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
104
+ duration_ms: (seconds * 1000).round }
105
+ # Its work stays: the UIs say so, as after a cancel.
106
+ steps = LLM::FailedTurn.kept_steps(error)
107
+ failed[:kept_steps] = steps if steps
108
+ # A provider error says what kind it is, for one line per kind in the UIs.
109
+ if error.is_a?(LLM::ProviderError)
110
+ failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
111
+ end
112
+ failed
113
+ end
114
+
115
+ # JSON-safe digest of a finished turn for renderers (in-process or over the
116
+ # Bridge): the bits of the native loop's result a UI needs beyond `result:`.
117
+ # @param result [LLM::ModelResult]
118
+ # @return [Hash]
119
+ def turn_summary(result)
120
+ summary = {
121
+ output: result.output.to_s,
122
+ exhausted: result.exhausted?,
123
+ resumable: result.resumable?,
124
+ pending_tool_calls: result.pending_tool_calls?,
125
+ tool_activity: Array(result.tool_activity).map(&:dup),
126
+ context_status: result.context_status&.dup
127
+ }
128
+ # No answer: every UI shows its notice (TurnNote.empty_answer_line).
129
+ summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
130
+ summary
131
+ end
132
+
133
+ private
134
+
135
+ def failure_summary(error) = @failure_summary.call(error)
136
+ end
137
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.110.0"
4
+ VERSION = "0.111.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end