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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +37 -23
- data/docs/configuration.md +1 -1
- data/docs/internals/block-renderers.md +2 -2
- data/docs/internals/bridge.md +5 -4
- data/docs/internals/context-telemetry.md +13 -1
- data/docs/internals/engine.md +4 -4
- data/docs/internals/inline-widgets.md +1 -1
- data/docs/internals/objectives.md +1 -1
- data/docs/internals/participants.md +235 -119
- data/docs/internals/web-frontend.md +164 -33
- data/docs/internals/working-set.md +69 -5
- data/docs/releasing.md +2 -1
- data/docs/sessions.md +6 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
- data/lib/samagotchi/bridge.rb +39 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +13 -0
- data/lib/samagotchi/engine.rb +5 -1
- data/lib/samagotchi/event_fanout.rb +22 -1
- data/lib/samagotchi/grants.rb +5 -0
- data/lib/samagotchi/guardrails/review_paths.rb +2 -2
- data/lib/samagotchi/image_store.rb +51 -12
- data/lib/samagotchi/kernel_loop.rb +7 -1
- data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
- data/lib/samagotchi/llm_context_stale.rb +4 -1
- data/lib/samagotchi/participant_messages.rb +39 -0
- data/lib/samagotchi/participant_revise.rb +61 -0
- data/lib/samagotchi/participant_uploads.rb +98 -0
- data/lib/samagotchi/reply_wait/poll.rb +5 -1
- data/lib/samagotchi/revise_anchor.rb +9 -0
- data/lib/samagotchi/send_command.rb +5 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager/delivery.rb +7 -0
- data/lib/samagotchi/session_stores.rb +2 -0
- data/lib/samagotchi/tool_runner.rb +39 -5
- data/lib/samagotchi/tools/memory.rb +26 -7
- data/lib/samagotchi/turn_anchors.rb +0 -3
- data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
- data/lib/samagotchi/verify_anchor.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +42 -77
- data/lib/samagotchi/web/participant_access.rb +129 -26
- data/lib/samagotchi/web/participant_images.rb +185 -0
- data/lib/samagotchi/web/participant_review.rb +19 -138
- data/lib/samagotchi/web/participant_show.rb +240 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
- data/lib/samagotchi/web/participant_view.rb +331 -24
- data/lib/samagotchi/web/public/annotate_bar.css +1 -2
- data/lib/samagotchi/web/public/annotate_bar.js +1 -2
- data/lib/samagotchi/web/public/app.js +124 -88
- data/lib/samagotchi/web/public/block_host.js +7 -4
- data/lib/samagotchi/web/public/bubbles.js +9 -1
- data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
- data/lib/samagotchi/web/public/chip_popover.js +5 -4
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer.css +4 -6
- data/lib/samagotchi/web/public/composer_mode.js +11 -6
- data/lib/samagotchi/web/public/composer_mount.js +4 -4
- data/lib/samagotchi/web/public/ctx.js +12 -3
- data/lib/samagotchi/web/public/delivery_line.js +34 -5
- data/lib/samagotchi/web/public/history_shape.js +5 -1
- data/lib/samagotchi/web/public/images.js +7 -1
- data/lib/samagotchi/web/public/index.html +40 -4
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +5 -4
- data/lib/samagotchi/web/public/owner_claim.js +3 -3
- data/lib/samagotchi/web/public/owner_verify.js +0 -2
- data/lib/samagotchi/web/public/participant_bar.js +108 -0
- data/lib/samagotchi/web/public/participant_main.js +289 -0
- data/lib/samagotchi/web/public/participant_model.js +5 -292
- data/lib/samagotchi/web/public/participant_names.js +6 -1
- data/lib/samagotchi/web/public/participant_revise.js +66 -0
- data/lib/samagotchi/web/public/participants_panel.js +3 -12
- data/lib/samagotchi/web/public/question_cards.js +14 -3
- data/lib/samagotchi/web/public/refs.js +53 -36
- data/lib/samagotchi/web/public/reload_check.js +68 -11
- data/lib/samagotchi/web/public/revise_stack.js +21 -10
- data/lib/samagotchi/web/public/send_turn.js +24 -6
- data/lib/samagotchi/web/public/session_fields.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +12 -4
- data/lib/samagotchi/web/public/session_sync.js +9 -2
- data/lib/samagotchi/web/public/session_view.js +17 -7
- data/lib/samagotchi/web/public/stage_asking.js +6 -4
- data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
- data/lib/samagotchi/web/public/stage_view.js +18 -4
- data/lib/samagotchi/web/public/stage_waiting.js +74 -6
- data/lib/samagotchi/web/public/storage.js +22 -0
- data/lib/samagotchi/web/public/stream_handlers.js +2 -1
- data/lib/samagotchi/web/public/tokens.css +1 -1
- data/lib/samagotchi/web/public/turn_block.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -6
- data/lib/samagotchi/web/public/turn_html.js +9 -2
- data/lib/samagotchi/web/public/turn_range.js +1 -2
- data/lib/samagotchi/web/public/verify_target.js +2 -4
- data/lib/samagotchi/web/public/viewer.js +59 -0
- data/lib/samagotchi/web/public/viewer_self.js +53 -0
- data/lib/samagotchi/web/public/working_set.js +11 -5
- data/lib/samagotchi/web/public/working_set_view.js +53 -8
- data/lib/samagotchi/web/public/ws_cards.js +89 -0
- data/lib/samagotchi/web/public/ws_peek.js +106 -0
- data/lib/samagotchi/web/public/ws_preview.css +126 -0
- data/lib/samagotchi/web/public/ws_reader.js +319 -0
- data/lib/samagotchi/web/public/ws_timeline.js +200 -0
- data/lib/samagotchi/web/public/ws_way_in.js +25 -0
- data/lib/samagotchi/web/session_control.rb +39 -14
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +75 -28
- data/lib/samagotchi/web/viewer.rb +70 -0
- data/lib/samagotchi/web/working_set_routes.rb +132 -0
- data/lib/samagotchi/worker.rb +19 -14
- data/lib/samagotchi/working_set.rb +260 -8
- metadata +20 -6
- data/lib/samagotchi/web/public/participant.html +0 -218
- data/lib/samagotchi/web/public/participant.js +0 -719
- data/lib/samagotchi/web/public/participant_annotate.js +0 -76
- data/lib/samagotchi/web/public/review_history.js +0 -72
- 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
|
|
16
|
-
# (
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
|
36
|
-
#
|
|
26
|
+
# Whether this request reads as a participant (their show, their
|
|
27
|
+
# filtered stream).
|
|
37
28
|
def participant_view?(req)
|
|
38
|
-
!principal(req).owner?
|
|
29
|
+
!principal(req).owner?
|
|
39
30
|
end
|
|
40
31
|
|
|
41
|
-
# Who reads: the participant (their author)
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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
|
|
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,
|
|
165
|
-
# grant's exposure shows it)
|
|
166
|
-
#
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|