samagotchi 0.170.0 → 0.171.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -5,46 +5,35 @@ require_relative "token"
5
5
  require_relative "participant_stream_filter"
6
6
  require_relative "../participant_exposure"
7
7
  require_relative "../session_metrics"
8
- require_relative "../review_start"
9
- require_relative "../guardrails/review_paths"
10
8
  require_relative "../participant_mode"
11
9
  require_relative "../verify_fresh"
12
10
 
13
11
  module Samagotchi
14
12
  module Web
15
- # What a participant reads, mixed into App (P4a): their page, its data
16
- # (GET /api/sessions/:id/review) and their stream, each through
17
- # ParticipantView. The owner reaches the same reads as "the participant
18
- # view": the page at /?as=participant, the review route, and the stream
19
- # with ?as=participant, through the same filter with no participant of
20
- # their own (nothing is "mine", no composer). See
21
- # docs/internals/participants.md (What a participant sees).
13
+ # What a participant reads, mixed into App: the reads their main page's
14
+ # show is made of (ParticipantShow: the viewer, their usage, the
15
+ # messages as they may read them) and their stream, each through
16
+ # ParticipantView. See docs/internals/participants.md (What a
17
+ # participant sees).
22
18
  module ParticipantReview
23
- PARTICIPANT_VIEW = "participant"
24
19
  # The stream's query keys a filtered stream passes to the Bridge: its
25
20
  # cursor and its join. Not client_id (a stream named as a TUI's would
26
21
  # count as one), not raw.
27
22
  STREAM_KEYS = %w[from_seq snapshot].freeze
28
- # How much of each review subject the page gets; the rest is cut
29
- # (+truncated+).
30
- SUBJECT_BYTES = 256 * 1024
31
- MARKDOWN_EXT = %w[.md .markdown .mdown .mkd].freeze
32
23
 
33
24
  private
34
25
 
35
- # Whether this request reads as a participant: a participant's, or
36
- # the owner's with ?as=participant.
26
+ # Whether this request reads as a participant (their show, their
27
+ # filtered stream).
37
28
  def participant_view?(req)
38
- !principal(req).owner? || req.params["as"] == PARTICIPANT_VIEW
29
+ !principal(req).owner?
39
30
  end
40
31
 
41
- # Who reads: the participant (their author), or the owner through the
42
- # participant view (nobody's own). The session's folder places tool
43
- # paths; the owner's token is never shown, nor (LAN) its loopback
44
- # cookie value.
32
+ # Who reads: the participant (their author). The session's folder
33
+ # places tool paths; the owner's token is never shown, nor (LAN) its
34
+ # loopback cookie value.
45
35
  def participant_viewer(req, session)
46
- author = principal(req).owner? ? nil : principal(req).grant.author
47
- Redact::Viewer.new(author: author, cwd: session.working_directory, secrets: owner_secrets)
36
+ Redact::Viewer.new(author: principal(req).grant.author, cwd: session.working_directory, secrets: owner_secrets)
48
37
  end
49
38
 
50
39
  def owner_secrets
@@ -52,82 +41,6 @@ module Samagotchi
52
41
  [token, @lan && Token.loopback_cookie(token)].compact
53
42
  end
54
43
 
55
- # GET /api/sessions/:id/review: the participant page's data, always
56
- # filtered (the owner gets the participant view of it).
57
- # @return {viewer, session, subjects, participants, messages,
58
- # current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}
59
- def handle_review(req, id)
60
- session = @session_class.load(id, state_dir: default_state_dir)
61
- features = ParticipantMode.live_features(session, default_state_dir)
62
- mode = ParticipantMode.of(session, features: features).to_s
63
- viewer = participant_viewer(req, session)
64
- live = bridge_get_json(id, "snapshot")
65
- turn_snapshot = live && live["snapshot"]
66
- state = live && live["session_state_snapshot"]
67
- raw = live_or_saved(turn_snapshot, "messages") { session.messages }
68
- # No html here: ParticipantView drops a report and the owner's html; with_reply_html renders the redacted replies.
69
- shown = messages_for_display(visible_replies(raw), parts: true, cwd: session.working_directory, markdown: false)
70
- current = turn_snapshot && ParticipantView.current_turn(turn_snapshot["current_turn"], viewer)
71
- automatic = automatic_turn_ids(id, session, shown, current, state)
72
- waiting = !!(current&.dig("waiting_for_owner") || turn_snapshot&.dig("continue_offer") ||
73
- (turn_snapshot && !turn_snapshot["current_turn"] && turn_snapshot["pending_question"]))
74
- queued = turn_snapshot && snapshot_list(turn_snapshot, "queued")
75
- status = displayed_status(session, state)
76
- status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && current.nil?
77
- json_response(200, {
78
- viewer: review_viewer(req, id, session: session, state: state, queued: queued, viewer: viewer, mode: mode),
79
- session: { id: session.id, title: participant_title(session), status: status,
80
- verify_unavailable: VerifyAnchor.worker_unavailable?(features) },
81
- subjects: review_subjects(session, viewer),
82
- participants: participant_names(id).map { |entry| entry.slice(:id, :name, :kind) },
83
- messages: with_reply_html(ParticipantView.messages(shown, viewer, automatic: automatic)),
84
- current_turn: current,
85
- waiting_for_owner: waiting,
86
- queued: review_queue(queued || [], viewer),
87
- verdicts: ParticipantView.verdicts(VerifyFresh.verdicts_of(session, Session.session_dir(id, state_dir: default_state_dir)), viewer),
88
- # The verdicts a fresh check runs for now (their check_turn_id;
89
- # never the child's id: a participant can't open it).
90
- fresh_running: VerifyFresh.running(id, state_dir: default_state_dir).map { |row| row[:of] }.uniq,
91
- last_event_id: turn_snapshot && turn_snapshot["event_id"]
92
- })
93
- end
94
-
95
- # A review session's subjects as the page shows them (the Plan view):
96
- # each {path, text, html (markdown only), truncated}, or {path,
97
- # missing: true} for one that is gone or that ReviewPaths refuses now
98
- # (it became a symlink out of the project, say): checked at every
99
- # read, as a review turn's read is. The text is cut at SUBJECT_BYTES
100
- # and redacted like everything else a participant gets; [] for a
101
- # session that isn't a review session.
102
- def review_subjects(session, viewer)
103
- review = session.review or return []
104
- root = ReviewStart.root_for(session.working_directory)
105
- check = Guardrails::ReviewPaths.new(root: root)
106
- review.subjects.map do |path|
107
- # The real path is checked and read, the file with O_NOFOLLOW: a
108
- # symlink swapped in for it after the check isn't followed. Only
109
- # the last component: a folder swapped for a symlink in between
110
- # still is, which takes write access to the project (the owner's).
111
- real = File.realpath(File.expand_path(path, root))
112
- next { path: path, missing: true } unless File.file?(real) && check.refusal([real]).nil?
113
-
114
- review_subject(path, real, viewer)
115
- rescue SystemCallError
116
- { path: path, missing: true }
117
- end
118
- end
119
-
120
- def review_subject(path, full, viewer)
121
- raw = File.open(full, File::RDONLY | File::NOFOLLOW | File::BINARY) { |file| file.read(SUBJECT_BYTES + 1) }.to_s
122
- text = Redact.text(raw.byteslice(0, SUBJECT_BYTES).force_encoding(Encoding::UTF_8).scrub, viewer)
123
- entry = { path: path, text: text, truncated: raw.bytesize > SUBJECT_BYTES }
124
- markdown = MARKDOWN_EXT.include?(File.extname(path).downcase)
125
- entry[:html] = @markdown_renderer.render(text) if markdown && @markdown_renderer.available?
126
- entry
127
- rescue SystemCallError
128
- { path: path, missing: true }
129
- end
130
-
131
44
  # The saved messages with each model message's content as a
132
45
  # participant may read it (ParticipantView.visible_raw: no cut-off
133
46
  # thought or tool call left as text).
@@ -161,14 +74,10 @@ module Samagotchi
161
74
  def delegate_child?(session) = !session.parent_id.to_s.empty?
162
75
 
163
76
  # Who is reading: a participant (their id, name, whether they may
164
- # comment: not once their cap is reached, and their +usage+ as their
165
- # grant's exposure shows it), or the owner looking through the
166
- # participant view (no composer, no usage: the owner has /stats).
167
- # +state+ the live worker's session state (nil without one), +queued+
168
- # its queue.
169
- def review_viewer(req, id, session:, state:, queued:, viewer:, mode:)
170
- return { kind: "owner", can_comment: false, mode: mode } if principal(req).owner?
171
-
77
+ # comment: not once their cap is reached, their grant's mode, and
78
+ # their +usage+ as their grant's exposure shows it). +state+ the live
79
+ # worker's session state (nil without one), +queued+ its queue.
80
+ def review_viewer(req, id, session:, state:, queued:, viewer:)
172
81
  grant = principal(req).grant
173
82
  capped = !participant_cap_refusal(grant).nil?
174
83
  # Theirs: pair only when their grant was issued to act (ParticipantMode.live_for).
@@ -231,14 +140,6 @@ module Samagotchi
231
140
  nil
232
141
  end
233
142
 
234
- # The queue as a participant sees it: their own queued turns (the
235
- # enqueued id, for dequeue, and the text), and how many others wait.
236
- def review_queue(queued, viewer)
237
- mine, others = queued.partition { |entry| entry.is_a?(Hash) && viewer.own?(entry["author"]) }
238
- { mine: mine.map { |entry| Redact.deep({ "enqueued_id" => entry["enqueued_id"].to_s, "prompt" => entry["prompt"].to_s }, viewer) },
239
- others: others.size }
240
- end
241
-
242
143
  # Each reply's rendered markdown, from its filtered (redacted) text.
243
144
  def with_reply_html(messages)
244
145
  return messages unless @markdown_renderer.available?
@@ -250,33 +151,13 @@ module Samagotchi
250
151
  end
251
152
  end
252
153
 
253
- # The page a participant's link opens, and the owner's participant
254
- # view (/?as=participant#s=<id>): the participant page's shell; its
255
- # script reads everything through the review route and the filtered
256
- # stream.
257
- def participant_shell(req)
258
- path = File.join(@public_dir, "participant.html")
259
- return error_response(500, "missing_page", "participant.html is missing") unless File.file?(path)
260
-
261
- attrs = { "version" => Samagotchi::VERSION }
262
- if principal(req).owner?
263
- attrs["viewer"] = "owner"
264
- else
265
- grant = principal(req).grant
266
- attrs.merge!("viewer" => "participant", "session" => grant.session_id, "name" => grant.name)
267
- end
268
- # The owner's participant view has no composer; a participant's
269
- # selection quotes with the same presets as the owner's page.
270
- attrs.merge!(annotate_presets_data) unless principal(req).owner?
271
- # A block: a name's \& or \' is text, never a backreference.
272
- html_response(200, File.read(path).sub("<body>") { "<body #{body_data(attrs)}>" })
273
- end
274
-
275
154
  # A filtered stream's frame filter (App#stream_filter), for the
276
155
  # session +id+.
277
156
  def participant_stream_filter(req, id)
278
157
  session = @session_class.load(id, state_dir: default_state_dir)
279
- ParticipantStreamFilter.new(participant_viewer(req, session))
158
+ grant = principal(req).grant
159
+ own = ->(enqueued_id) { own_turn?(grant, id, enqueued_id) }
160
+ ParticipantStreamFilter.new(participant_viewer(req, session), own_enqueued: own)
280
161
  end
281
162
 
282
163
  # A filtered stream's query: only its cursor and its join.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "participant_view"
5
+ require_relative "viewer"
6
+ require_relative "../bridge/turn_accumulator"
7
+ require_relative "../participant_mode"
8
+ require_relative "../verify_anchor"
9
+ require_relative "../verify_fresh"
10
+
11
+ module Samagotchi
12
+ module Web
13
+ # A participant's GET /api/sessions/:id, mixed into App: the main page's
14
+ # session read in the owner's shape (SessionShow#owner_show's keys), made
15
+ # from the same reads (SessionShow#show_reads) and every part passed
16
+ # through ParticipantView: the messages, each finished turn's drawing
17
+ # event by event, the turn in progress, the replay and the queue. What a
18
+ # participant never gets is there empty or nil (history, commands, cards,
19
+ # recap, the continue offer, the question, the warnings); the only keys
20
+ # the owner's show lacks are +viewer+'s own and the verdicts' (+fresh_running+,
21
+ # +session.verify_unavailable+). The light re-reads (?tail=1, ?timing=1,
22
+ # ?cards=1) answer in the owner's shape too, filtered; ?brief=1 is what
23
+ # SessionAccess::Remote#show reads with a participant's token.
24
+ # See docs/internals/participants.md (What a participant sees).
25
+ module ParticipantShow
26
+ private
27
+
28
+ def participant_show_read(req, id)
29
+ return participant_show(id) if req.params["brief"] == "1"
30
+ return participant_cards_read(id) if req.params["cards"] == "1"
31
+ return participant_tail_read(req, id) if req.params["tail"] == "1"
32
+ return participant_timing_read(req, id) if req.params["timing"] == "1"
33
+
34
+ session = @session_class.load(id, state_dir: default_state_dir)
35
+ participant_main_show(req, show_reads(id, session))
36
+ end
37
+
38
+ # The owner's show's keys, a participant's values (the class comment).
39
+ def participant_main_show(req, reads)
40
+ session = reads.session
41
+ viewer = participant_viewer(req, session)
42
+ turn_snapshot = reads.turn_snapshot
43
+ features = ParticipantMode.live_features(session, default_state_dir)
44
+ timing = participant_timing(reads.id, live_metrics: reads.state && reads.state["metrics"])
45
+ raw = visible_replies(reads.raw_messages)
46
+ drawn = drawn_lines(reads.id, raw, timing: {}, entries: [], cwd: session.working_directory,
47
+ skip_turn_id: reads.current_turn_id)
48
+ shown = messages_for_display(raw, parts: true, cwd: session.working_directory, markdown: false, drawings: drawn)
49
+ current = turn_snapshot && ParticipantView.current_turn(reads.current_turn, viewer)
50
+ automatic = automatic_turn_ids(reads.id, session, shown, current, reads.state)
51
+ messages = with_reply_html(ParticipantView.messages(shown, viewer, automatic: automatic))
52
+ queued = snapshot_list(turn_snapshot, "queued")
53
+ json_response(200, {
54
+ session: participant_session_json(reads, features, viewer),
55
+ history: [],
56
+ messages: messages,
57
+ **participant_drawings_field(drawn, messages, viewer),
58
+ failed_turn: nil,
59
+ current_turn: current,
60
+ replay: participant_replay(turn_snapshot, timing, viewer),
61
+ queued: participant_queued(queued, viewer),
62
+ queued_commands: [],
63
+ recap: nil,
64
+ saved_recap: nil,
65
+ continue_offer: nil,
66
+ guardrail_warning: nil,
67
+ plugin_warning: nil,
68
+ init_tasks: [],
69
+ cards: [],
70
+ commands: [],
71
+ # The owner's chi web's own setup notes: never a participant's.
72
+ markdown_warning: nil,
73
+ markdown_tip: nil,
74
+ **participant_capabilities,
75
+ viewer: participant_main_viewer(req, reads, queued: turn_snapshot && queued, viewer: viewer),
76
+ pending_question: nil,
77
+ last_event_seq: reads.event_seq,
78
+ last_event_id: reads.event_id,
79
+ timing: timing,
80
+ fresh_running: VerifyFresh.running(reads.id, state_dir: default_state_dir).map { |row| row[:of] }.uniq
81
+ })
82
+ end
83
+
84
+ # The show's session: who it is (id, the participant title), its status
85
+ # as a participant reads it (idle when a worker says running with no
86
+ # turn), whether a worker runs it and what it can do (the page's
87
+ # delivery line, the stale-worker badge), its verdicts (filtered) and whether Verify fresh can run.
88
+ def participant_session_json(reads, features, viewer)
89
+ session = reads.session
90
+ status = displayed_status(session, reads.state)
91
+ status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && reads.turn_snapshot && reads.current_turn.nil?
92
+ dir = Session.session_dir(reads.id, state_dir: default_state_dir)
93
+ { id: session.id, title: participant_title(session), status: status,
94
+ bridge_up: !reads.turn_snapshot.nil?, worker_features: Array(features),
95
+ verdicts: ParticipantView.verdicts(VerifyFresh.verdicts_of(session, dir), viewer),
96
+ verify_unavailable: VerifyAnchor.worker_unavailable?(features) }
97
+ end
98
+
99
+ # The show's capabilities (Capabilities, what the page renders): never
100
+ # the editor (it opens this machine's paths), whoever's machine it is.
101
+ def participant_capabilities
102
+ { capabilities: Capabilities.for(@markdown_renderer, editor: false).to_h, editor_url: nil }
103
+ end
104
+
105
+ # The show's viewer: Viewer's (kind, id, name, can, client_id) with what
106
+ # #review_viewer says (their mode, cap,
107
+ # usage) and whether the session is stopped (only the owner's message
108
+ # starts it again).
109
+ def participant_main_viewer(req, reads, queued:, viewer:)
110
+ session = reads.session
111
+ review = review_viewer(req, reads.id, session: session, state: reads.state, queued: queued, viewer: viewer)
112
+ Viewer.for(principal(req)).to_h.merge(
113
+ review.slice(:participant_kind, :mode, :cap_reached, :usage),
114
+ session_stopped: session.status == Session::STATUS_STOPPED,
115
+ waiting_for_owner: participant_waiting(reads.turn_snapshot)
116
+ )
117
+ end
118
+
119
+ # What the session waits for the owner on (ParticipantView.waiting_kind),
120
+ # nil when it doesn't: the running turn's question or approval, a
121
+ # Continue offer, or a question between turns.
122
+ def participant_waiting(turn_snapshot)
123
+ return nil unless turn_snapshot.is_a?(Hash)
124
+
125
+ turn = turn_snapshot["current_turn"]
126
+ pending = turn.is_a?(Hash) ? turn["pending_question"] : turn_snapshot["pending_question"]
127
+ return ParticipantView.waiting_kind(pending) if pending
128
+ return ParticipantView::WAITING_CONTINUE if turn_snapshot["continue_offer"]
129
+
130
+ nil
131
+ end
132
+
133
+ # {turn_drawings: {turn_id => {events:, end:}}} as the owner's
134
+ # (SessionShow#turn_drawings_field), each event through
135
+ # ParticipantView.event (a dropped one is left out) and the end
136
+ # through ParticipantView.drawing_end. No cards. Its start's prompt is
137
+ # the filtered messages' (ParticipantView.drawing_start): a turn they
138
+ # show no person's prompt for (an automatic turn, a wake, a delegate
139
+ # child's task; a made drawing's start has no origin to say so)
140
+ # starts with none.
141
+ def participant_drawings_field(lines, messages, viewer)
142
+ stamped = messages.to_set { |item| item["drawn"] }
143
+ shown = lines.select { |line| stamped.include?(line.turn_id) }
144
+ return {} if shown.empty?
145
+
146
+ prompts = messages.each_with_object({}) do |message, by_turn|
147
+ next unless message["role"] == "user" && message["turn_id"] && !message["automatic"] && !message["merged"]
148
+
149
+ by_turn[message["turn_id"]] = message["content"]
150
+ end
151
+ { turn_drawings: shown.to_h do |line|
152
+ turn = Bridge::TurnAccumulator.from_wire(line.drawing)
153
+ events = participant_events(Bridge::TurnAccumulator.replay_events(turn, cards: false), viewer)
154
+ events = events.map { |event| ParticipantView.drawing_start(event, prompts[line.turn_id.to_s]) }
155
+ [line.turn_id, { events: events, end: ParticipantView.drawing_end(line.end || {}, line.turn_id) }]
156
+ end }
157
+ end
158
+
159
+ # The page's join (SessionShow#replay_events_for), each event through
160
+ # ParticipantView.event: another person's queued turn is a bare
161
+ # turn_enqueued (its entry holds their prompt).
162
+ def participant_replay(turn_snapshot, timing, viewer)
163
+ participant_events(replay_events_for(turn_snapshot, timing), viewer)
164
+ end
165
+
166
+ def participant_events(events, viewer)
167
+ Array(events).filter_map { |event| ParticipantView.event(JSON.parse(JSON.generate(event)), viewer) }
168
+ end
169
+
170
+ # The queue as the owner's show lists it, the viewer's own entries only
171
+ # (ParticipantView.queued_entry): another person's entry holds their
172
+ # prompt.
173
+ def participant_queued(queued, viewer)
174
+ queued.filter_map { |entry| ParticipantView.queued_entry(entry, viewer) }
175
+ end
176
+
177
+ # ?cards=1: a participant gets no cards.
178
+ def participant_cards_read(id)
179
+ return session_not_found(id) unless session_exists?(id)
180
+
181
+ json_response(200, { cards: [] })
182
+ end
183
+
184
+ # ?tail=1 as the owner's (SessionShow#handle_tail_read): the session's
185
+ # id and status, the turn's answer through ParticipantView (the last
186
+ # one, or ?turn_id='s), the timing filtered. Read from the live
187
+ # snapshot (the messages a participant may read are filtered as the
188
+ # full show's), else the file.
189
+ def participant_tail_read(req, id)
190
+ session = @session_class.load(id, state_dir: default_state_dir)
191
+ reads = show_reads(id, session)
192
+ viewer = participant_viewer(req, session)
193
+ turn_id = req.params["turn_id"].to_s.strip
194
+ raw = visible_replies(reads.raw_messages)
195
+ shown = messages_for_display(raw, parts: true, cwd: session.working_directory, markdown: false)
196
+ messages = ParticipantView.messages(shown, viewer)
197
+ answer = participant_answer(messages, turn_id.empty? ? nil : turn_id)
198
+ status = displayed_status(session, reads.state)
199
+ status = Session::STATUS_IDLE if status == Session::STATUS_RUNNING && reads.turn_snapshot && reads.current_turn.nil?
200
+ json_response(200, {
201
+ tail: true,
202
+ session: { id: id, status: status, used_memory_names: [], prompt_notes: [] },
203
+ messages: with_reply_html([answer].compact),
204
+ # The owner's chi web's own setup notes: never a participant's.
205
+ markdown_warning: nil,
206
+ markdown_tip: nil,
207
+ **participant_capabilities,
208
+ timing: participant_timing(id, live_metrics: reads.state && reads.state["metrics"])
209
+ })
210
+ end
211
+
212
+ # The answer of the turn +turn_id+ (the reply after its prompt), else
213
+ # the last reply with text.
214
+ def participant_answer(messages, turn_id)
215
+ if turn_id
216
+ start = messages.index { |message| message["role"] == "user" && message["turn_id"] == turn_id }
217
+ return nil unless start
218
+
219
+ turn = messages.drop(start + 1).take_while { |message| message["role"] != "user" || message["merged"] }
220
+ return turn.reverse.find { |message| message["role"] == "assistant" && !message["content"].strip.empty? }
221
+ end
222
+ messages.reverse.find { |message| message["role"] == "assistant" && !message["content"].strip.empty? }
223
+ end
224
+
225
+ # ?timing=1: the whole timing, filtered.
226
+ def participant_timing_read(_req, id)
227
+ return session_not_found(id) unless session_exists?(id)
228
+
229
+ state = bridge_get_json(id, "state")
230
+ json_response(200, { timing: participant_timing(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
231
+ end
232
+
233
+ # The show's timing (SessionShow#timing_payload) as a participant may
234
+ # read it (ParticipantView.timing: durations and speeds only).
235
+ def participant_timing(id, live_metrics:)
236
+ ParticipantView.timing(timing_payload(id, live_metrics: live_metrics))
237
+ end
238
+ end
239
+ end
240
+ end
@@ -46,8 +46,30 @@ module Samagotchi
46
46
  PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/
47
47
  PEM_END = /-----END [A-Z0-9 ]*PRIVATE KEY-----/
48
48
 
49
- def initialize(viewer)
49
+ # The enqueued ids of the viewer's own turns: those whose
50
+ # :turn_enqueued this stream sent them as theirs, and any +known+
51
+ # names (chi web's record of who sent a turn, ParticipantAccess#own_turn?).
52
+ class OwnEnqueued
53
+ MAX_IDS = 1000
54
+
55
+ def initialize(known)
56
+ @known = known
57
+ @ids = []
58
+ end
59
+
60
+ def add(id)
61
+ @ids << id
62
+ @ids.shift while @ids.size > MAX_IDS
63
+ end
64
+
65
+ def include?(id) = @ids.include?(id) || (!@known.nil? && @known.call(id) == true)
66
+ end
67
+
68
+ # @param own_enqueued [#call, nil] (enqueued_id) -> whether the viewer
69
+ # sent that turn, for one this stream didn't see queued
70
+ def initialize(viewer, own_enqueued: nil)
50
71
  @viewer = viewer
72
+ @own = OwnEnqueued.new(own_enqueued)
51
73
  @buffer = +""
52
74
  @skipping = false
53
75
  @held = +""
@@ -97,7 +119,8 @@ module Samagotchi
97
119
  end
98
120
  end
99
121
  event = parse(data.join("\n")) or return ""
100
- shown = ParticipantView.event(event, @viewer) or return ""
122
+ shown = ParticipantView.event(event, @viewer, own_enqueued: @own) or return ""
123
+ @own.add(shown["enqueued_id"]) if shown["type"] == "turn_enqueued" && shown["enqueued_id"]
101
124
  id = nil unless id && EVENT_ID.match?(id)
102
125
  return chunk(event, shown, id) if shown["type"] == "generation_chunk"
103
126