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
@@ -7,6 +7,8 @@ require_relative "../participant_mode"
7
7
  require_relative "../revise_anchor"
8
8
  require_relative "../verify_anchor"
9
9
  require_relative "../redact"
10
+ require_relative "../file_ref"
11
+ require_relative "../image_store"
10
12
 
11
13
  module Samagotchi
12
14
  module Web
@@ -16,7 +18,10 @@ module Samagotchi
16
18
  # waiting states. Verdict cards expose only their anchor, verdict,
17
19
  # reason and author. Never: tool output or arguments, thinking, context
18
20
  # notes, plugin steers, automatic input, other cards, commands,
19
- # questions' internals, memories, the working set, cost or timing, host paths.
21
+ # questions' internals, memories, the working set's memories and URLs,
22
+ # cost, tokens per model, host paths. Numbers they read: the context's
23
+ # fill, the speed, the turn times (#context_status, #generation_completed,
24
+ # #timing).
20
25
  #
21
26
  # Every read a participant reaches goes through here: the stream's
22
27
  # events (#event, ParticipantStreamFilter), the review route's
@@ -42,20 +47,28 @@ module Samagotchi
42
47
  # fields only (#verdict_record).
43
48
  "verdict_recorded" => :verdict_recorded,
44
49
  "generation_chunk" => :generation_chunk,
50
+ # A generation's end: its decode speed only (the composer's tok/s).
51
+ "generation_completed" => :generation_completed,
52
+ # The context's fill (the composer's ctx %): its numbers only.
53
+ "context_status" => :context_status,
54
+ # The files chi works with (the stage's working set): the project's
55
+ # files only (#working_set).
56
+ "working_set_updated" => :working_set_updated,
45
57
  "generation_retrying" => :generation_retrying,
46
58
  "tool_call_started" => :tool_call_started,
47
59
  "tool_call_completed" => :tool_call_completed,
48
- # Queue changes: the page re-reads its review data, which has the
49
- # queue as the participant may see it.
50
- "turn_enqueued" => :bare,
51
- "turn_dequeued" => :bare,
52
- "input_merged" => :bare,
60
+ # Queue changes: the viewer's own in the owner's shape (the main
61
+ # page matches its bubbles by them), anyone else's bare (their
62
+ # prompts are theirs).
63
+ "turn_enqueued" => :turn_enqueued,
64
+ "turn_dequeued" => :turn_dequeued,
65
+ "input_merged" => :input_merged,
53
66
  # The owner's question, approval or Continue offer: "waiting for the
54
- # owner", nothing of it.
55
- "question_requested" => :bare,
67
+ # owner" and for what kind of thing (#waiting_kind), nothing of it.
68
+ "question_requested" => :question_requested,
56
69
  "question_answered" => :bare,
57
70
  "question_cancelled" => :bare,
58
- "continue_offered" => :bare,
71
+ "continue_offered" => :continue_offered,
59
72
  "continue_resolved" => :bare,
60
73
  # The cursor can't be replayed (or a join): the page re-reads.
61
74
  "snapshot" => :bare,
@@ -68,12 +81,12 @@ module Samagotchi
68
81
  # named is dropped all the same.
69
82
  DROPPED = %w[
70
83
  after_generation after_tool_call after_turn answer_display before_generation before_tool_call before_turn
71
- card command_output command_queued command_ran context_added context_status empty_answer_retry
72
- generation_cancelled generation_completed generation_progress generation_started guardrail_warning hook_notice
84
+ card command_output command_queued command_ran context_added empty_answer_retry
85
+ generation_cancelled generation_progress generation_started guardrail_warning hook_notice
73
86
  llm_context_edited merged_input objectives_updated participants_changed pending_input_merged plugin_init_finished
74
87
  plugin_init_started plugin_init_wait prompt_restored question question_relay reasoning_history_refused
75
88
  recap_ready reminder_injected sampling_refused session_end session_start side_usage steer_cut thinking_dropped
76
- thinking_refused tool_dispatch_completed tool_dispatch_started used_memories_updated working_set_updated
89
+ thinking_refused tool_dispatch_completed tool_dispatch_started used_memories_updated
77
90
  ].freeze
78
91
 
79
92
  # A merged input line's saved sources a participant sees: a person's
@@ -88,16 +101,53 @@ module Samagotchi
88
101
 
89
102
  # @param event [Hash] a client event (ClientEvent's shape; string keys
90
103
  # from the stream's JSON)
104
+ # @param own_enqueued [#include?] the enqueued ids of the viewer's own
105
+ # turns (a :turn_dequeued names no author; ParticipantStreamFilter's)
91
106
  # @return [Hash, nil] what a participant gets, string keys; nil: nothing
92
- def event(event, viewer)
107
+ def event(event, viewer, own_enqueued: [])
93
108
  return nil unless event.is_a?(Hash)
94
109
 
95
110
  type = field(event, :type).to_s
96
111
  method = EVENTS[type] or return nil
97
- out = send(method, event, viewer)
112
+ out = method == :turn_dequeued ? turn_dequeued(event, viewer, own_enqueued) : send(method, event, viewer)
98
113
  out && Redact.deep(out, viewer)
99
114
  end
100
115
 
116
+ # A turn queued: the viewer's own with its enqueued id, client id,
117
+ # prompt, delivery, author and image refs; anyone else's bare.
118
+ def turn_enqueued(event, viewer)
119
+ entry = queued_entry(event, viewer) or return bare(event, viewer)
120
+
121
+ { "type" => "turn_enqueued", **seq(event), **entry }
122
+ end
123
+
124
+ # A queued turn taken back or refused: the viewer's own with its
125
+ # enqueued id (never the client that took it back); anyone else's bare.
126
+ def turn_dequeued(event, viewer, own_enqueued)
127
+ enqueued_id = field(event, :enqueued_id)
128
+ return bare(event, viewer) unless enqueued_id.is_a?(String) && !enqueued_id.empty? && own_enqueued.include?(enqueued_id)
129
+
130
+ { "type" => "turn_dequeued", **seq(event), "enqueued_id" => enqueued_id }
131
+ end
132
+
133
+ # Input merged into the running turn: the viewer's own lines (their
134
+ # origins' enqueued ids) and how many, bare when none is theirs. The
135
+ # others' (a delegate's report, another person's line) are not named
136
+ # nor counted. No text: it comes with the turn's messages.
137
+ def input_merged(event, viewer)
138
+ own = Array(field(event, :origins)).filter_map do |origin|
139
+ next unless origin.is_a?(Hash) && viewer.own?(field(origin, :author))
140
+
141
+ enqueued_id = field(origin, :enqueued_id)
142
+ next unless enqueued_id.is_a?(String) && !enqueued_id.empty?
143
+
144
+ { "enqueued_id" => enqueued_id, "client_id" => field(origin, :client_id).to_s, "author" => field(origin, :author).to_s }
145
+ end
146
+ return bare(event, viewer) if own.empty?
147
+
148
+ { "type" => "input_merged", **seq(event), "count" => own.size, "origins" => own }
149
+ end
150
+
101
151
  # A turn's start: who wrote it, and its prompt when a person sent it
102
152
  # (an automatic turn's prompt, a reminder's or a task's, is not
103
153
  # shown: a person is the owner's web or TUI, chi send, a participant,
@@ -108,6 +158,8 @@ module Samagotchi
108
158
  author = field(event, :author) || origin.author
109
159
  out = { "type" => "turn_started", **id_fields(event), **time_field(event) }
110
160
  out.merge!(author_fields(author, field(event, :author_name), viewer, field(event, :participant_mode)))
161
+ images = image_refs(field(event, :images), author, viewer)
162
+ out["images"] = images if images
111
163
  prompt = field(event, :prompt)
112
164
  if field(event, :continue)
113
165
  out["continue"] = true
@@ -119,14 +171,30 @@ module Samagotchi
119
171
  out
120
172
  end
121
173
 
174
+ # A drawing's filtered turn_started (#event's) with +prompt+, the
175
+ # filtered messages' prompt of its turn; nil: an automatic turn's
176
+ # start, no prompt (a Continue's has none either way). Any other event
177
+ # as it is.
178
+ def drawing_start(event, prompt)
179
+ return event unless event["type"] == "turn_started"
180
+
181
+ out = event.except("prompt", "automatic")
182
+ return out if out["continue"]
183
+ return out.merge("prompt" => prompt) if prompt
184
+
185
+ out.except("continue").merge("automatic" => true)
186
+ end
187
+
122
188
  def turn_ended(event, viewer)
123
189
  out = { "type" => field(event, :type).to_s, **id_fields(event) }
124
190
  verdict = verdict_record(field(event, :verdict), viewer)
125
191
  out["verdict"] = verdict if verdict
126
- # The turn a completed Revise turn revised (as on its prompt; the
127
- # page doesn't draw stacks yet).
192
+ # The turn a completed Revise turn revised (as on its prompt), and
193
+ # whether it was a Verify turn (no Revise on its answer): a boolean,
194
+ # never the quote.
128
195
  revises = ReviseAnchor.from(field(event, :revises))
129
196
  out["revises"] = revises.to_file if revises
197
+ out["verify"] = true if field(event, :verify) == true
130
198
  out
131
199
  end
132
200
 
@@ -172,6 +240,82 @@ module Samagotchi
172
240
  { "type" => "generation_chunk", "text" => text, **iteration(event) }
173
241
  end
174
242
 
243
+ # A generation's end: its decode speed ({decode_tps, source}, the
244
+ # composer's tok/s). Never the session's tokens, cost or models
245
+ # (+tokens+, +model_segments+), nor the served model.
246
+ def generation_completed(event, _viewer)
247
+ out = { "type" => "generation_completed", **iteration(event) }
248
+ speed = field(event, :speed)
249
+ tps = speed.is_a?(Hash) ? field(speed, :decode_tps) : nil
250
+ if tps.is_a?(Numeric) && tps.to_f.finite?
251
+ source = field(speed, :source).to_s
252
+ out["speed"] = { "decode_tps" => tps, **(SPEED_SOURCES.include?(source) ? { "source" => source } : {}) }
253
+ end
254
+ out
255
+ end
256
+
257
+ SPEED_SOURCES = %w[server estimate].freeze
258
+
259
+ # The usage numbers of a context_status a participant reads: its fill
260
+ # and the token counts.
261
+ CONTEXT_USAGE_KEYS = %w[estimated_pct estimated_used_tokens estimated_remaining_tokens window_tokens].freeze
262
+
263
+ # The context's fill (ContextStatus#observe, Engine's switched
264
+ # context): usage's percentage and token counts (usage null: unknown
265
+ # after a model switch), the bucket, and the window after a switch.
266
+ # Never +status+ (the model-facing line, with its guidance), the
267
+ # counts' source or the window's.
268
+ def context_status(event, _viewer)
269
+ out = { "type" => "context_status", **iteration(event) }
270
+ if event.key?("usage") || event.key?(:usage)
271
+ usage = field(event, :usage)
272
+ out["usage"] = usage.is_a?(Hash) ? pick(usage, CONTEXT_USAGE_KEYS) : nil
273
+ end
274
+ bucket = field(event, :bucket)
275
+ out["bucket"] = bucket if bucket.is_a?(String) && bucket.match?(/\A(?:under|\d+plus)\d*\z/)
276
+ if event.key?("context_window_tokens") || event.key?(:context_window_tokens)
277
+ window = field(event, :context_window_tokens)
278
+ out["context_window_tokens"] = window.is_a?(Integer) ? window : nil
279
+ end
280
+ out
281
+ end
282
+
283
+ def working_set_updated(event, viewer)
284
+ { "type" => "working_set_updated", **seq(event), **working_set(event, viewer) }
285
+ end
286
+
287
+ # The working set's states and ops a participant's item may carry
288
+ # (WorkingSet::Item).
289
+ WORKING_SET_STATES = %w[live stale forgotten dropped].freeze
290
+ WORKING_SET_OPS = %w[read edit].freeze
291
+
292
+ # A working set (WorkingSet::Snapshot#to_h: the event's and GET
293
+ # …/working_set's) as a participant may read it: the file items inside
294
+ # the project, each key made project-relative, with its state, tokens,
295
+ # ops, count and pinned; +at+ (the page keeps the newer). Never a
296
+ # memory or URL item, a path outside the project, +first_id+,
297
+ # +has_drop+ or the pending +adds+.
298
+ def working_set(set, viewer)
299
+ items = Array(field(set, :items)).filter_map { |item| working_set_item(item, viewer) }
300
+ at = field(set, :at)
301
+ { "items" => items, **(at.is_a?(Integer) ? { "at" => at } : {}) }
302
+ end
303
+
304
+ def working_set_item(item, viewer)
305
+ return nil unless item.is_a?(Hash) && field(item, :kind).to_s == FileRef::KIND
306
+
307
+ key = project_path(field(item, :key), viewer.cwd, home: viewer.home) or return nil
308
+ state = field(item, :state).to_s
309
+ out = { "kind" => FileRef::KIND, "key" => key, "state" => WORKING_SET_STATES.include?(state) ? state : "live",
310
+ "ops" => Array(field(item, :ops)).map(&:to_s).select { |op| WORKING_SET_OPS.include?(op) }.uniq,
311
+ "pinned" => field(item, :pinned) == true }
312
+ %i[tokens count].each do |name|
313
+ value = field(item, name)
314
+ out[name.to_s] = value if value.is_a?(Integer) && value >= 0
315
+ end
316
+ out
317
+ end
318
+
175
319
  # A dropped stream asked again: its text so far is void when restarted.
176
320
  def generation_retrying(event, _viewer)
177
321
  out = { "type" => "generation_retrying", **iteration(event) }
@@ -183,14 +327,42 @@ module Samagotchi
183
327
  { "type" => "tool_call_started", **tool_row(field(event, :tool), field(event, :ref), viewer), **call_ids(event) }
184
328
  end
185
329
 
330
+ # Its status word, flat and as the owner's +activity+ ({status} only:
331
+ # never its action or params) for the main page's row (activity.js).
186
332
  def tool_call_completed(event, _viewer)
187
333
  out = { "type" => "tool_call_completed", **call_ids(event) }
188
334
  activity = field(event, :activity)
189
335
  status = activity.is_a?(Hash) ? field(activity, :status).to_s : ""
190
- out["status"] = status if STATUSES.include?(status)
336
+ out.merge!("status" => status, "activity" => { "status" => status }) if STATUSES.include?(status)
191
337
  out
192
338
  end
193
339
 
340
+ # A question for the owner: its kind (#waiting_kind) only.
341
+ def question_requested(event, _viewer)
342
+ { "type" => "question_requested", **seq(event), "kind" => waiting_kind(field(event, :pending_question)) }
343
+ end
344
+
345
+ # The owner's Continue offer: its kind only.
346
+ def continue_offered(event, _viewer)
347
+ { "type" => "continue_offered", **seq(event), "kind" => WAITING_CONTINUE }
348
+ end
349
+
350
+ WAITING_APPROVAL = "approval"
351
+ WAITING_QUESTION = "question"
352
+ WAITING_CONTINUE = "continue"
353
+
354
+ # What a turn waits for the owner on, derived from a pending question
355
+ # (PendingQuestion#to_h, a snapshot's): "approval" for a guardrail's
356
+ # approval or a relayed one, "continue" for the step-limit question,
357
+ # "question" for anything else (the model's own, a hook's, none).
358
+ def waiting_kind(pending)
359
+ return WAITING_QUESTION unless pending.is_a?(Hash)
360
+ return WAITING_APPROVAL if field(pending, :kind).to_s == "approval" || field(pending, :relay).is_a?(Hash)
361
+ return WAITING_CONTINUE if field(pending, :kind).to_s == "continue"
362
+
363
+ WAITING_QUESTION
364
+ end
365
+
194
366
  # Its type alone (and the cursor it was sent at).
195
367
  def bare(event, _viewer)
196
368
  { "type" => field(event, :type).to_s, **seq(event) }
@@ -242,14 +414,17 @@ module Samagotchi
242
414
  out["content"] = field(message, :content).to_s
243
415
  out["turn_id"] = turn_id.to_s if turn_id
244
416
  out["merged"] = true if merged
245
- # The turn it revised (a Revise stack; the page doesn't draw it yet).
417
+ # The turn it revised (a Revise stack), and whether it was a Verify
418
+ # turn (VerifyAnchor.marks?, ParticipantRevise's test too): a
419
+ # boolean only, never the quote.
246
420
  revises = ReviseAnchor.from(field(message, :revises))
247
421
  out["revises"] = revises.to_file if revises && turn_id && !merged
422
+ out["verify"] = true if turn_id && !merged && VerifyAnchor.marks?(field(message, :verify))
248
423
  out.merge!(author_fields(field(message, :author), field(message, :author_name), viewer,
249
424
  field(message, :participant_mode)))
250
- images = field(message, :images)
251
- out["images"] = images.size if images.is_a?(Array) && !images.empty?
252
- out
425
+ images = image_refs(field(message, :images), field(message, :author), viewer)
426
+ out["images"] = images if images
427
+ out.merge!(drawn_field(message))
253
428
  end
254
429
 
255
430
  # A wake turn's start (its prompt a delegate's report or a context
@@ -275,9 +450,33 @@ module Samagotchi
275
450
 
276
451
  out = { "role" => "assistant", "content" => content, **time_field(message) }
277
452
  out["tools"] = rows unless rows.empty?
453
+ out.merge!(drawn_field(message))
454
+ end
455
+
456
+ # The turn whose drawing draws this message (MessageDisplay's +drawn+
457
+ # stamp: a turn id), for the main page's show; none on the review read.
458
+ def drawn_field(message)
459
+ drawn = field(message, :drawn)
460
+ drawn.is_a?(String) && !drawn.empty? ? { "drawn" => drawn } : {}
461
+ end
462
+
463
+ # A finished turn's drawing end (Bridge::TurnDrawings' end, its
464
+ # END_KEYS) as a participant may read it: how it ended, its turn and
465
+ # duration, and how many steps a failure kept. Never a failure's
466
+ # summary, message or class (they may name a host path), a cancel's
467
+ # reason or who stopped it.
468
+ def drawing_end(raw, turn_id)
469
+ out = { "type" => (TURN_ENDS.include?(field(raw, :type).to_s) ? field(raw, :type).to_s : "turn_completed"),
470
+ "turn_id" => turn_id.to_s }
471
+ duration = field(raw, :duration_ms)
472
+ out["duration_ms"] = duration if duration.is_a?(Integer)
473
+ kept = field(raw, :kept_steps)
474
+ out["kept_steps"] = kept if kept.is_a?(Integer)
278
475
  out
279
476
  end
280
477
 
478
+ TURN_ENDS = %w[turn_completed turn_canceled turn_failed].freeze
479
+
281
480
  # The turn in progress (TurnAccumulator#current_turn, string keys
282
481
  # from the Bridge's snapshot) as a participant sees it: its start
283
482
  # (#turn_started's fields), the reply's text so far, its tool rows,
@@ -303,18 +502,126 @@ module Samagotchi
303
502
  end
304
503
  end
305
504
  out = started.merge("text" => text, "tools" => tools)
306
- out["waiting_for_owner"] = true if field(turn, :pending_question)
505
+ # The running generation's phase (Bridge#with_generation: the main
506
+ # page's delivery-line clock): whether it only thinks so far, and its
507
+ # age; nothing of what it thinks.
508
+ generation = field(turn, :generation)
509
+ if generation.is_a?(Hash)
510
+ gen = {}
511
+ gen["thinking_only"] = field(generation, :thinking_only) if [true, false].include?(field(generation, :thinking_only))
512
+ gen["age"] = field(generation, :age) if field(generation, :age).is_a?(Numeric)
513
+ out["generation"] = gen
514
+ end
515
+ pending = field(turn, :pending_question)
516
+ out.merge!("waiting_for_owner" => true, "kind" => waiting_kind(pending)) if pending
307
517
  Redact.deep(out, viewer)
308
518
  end
309
519
 
520
+ # A queued entry (a Bridge snapshot's +queued+ row) when it is the
521
+ # viewer's own, in the owner's shape: its enqueued id, client id,
522
+ # prompt, delivery, author and image refs (#image_refs). nil for
523
+ # another person's (their prompt).
524
+ def queued_entry(entry, viewer)
525
+ return nil unless entry.is_a?(Hash) && viewer.own?(field(entry, :author))
526
+
527
+ out = { "enqueued_id" => field(entry, :enqueued_id).to_s, "client_id" => field(entry, :client_id).to_s,
528
+ "prompt" => field(entry, :prompt).to_s, "author" => field(entry, :author).to_s }
529
+ delivery = field(entry, :delivery).to_s
530
+ out["delivery"] = delivery if DELIVERIES.include?(delivery)
531
+ images = image_refs(field(entry, :images), field(entry, :author), viewer)
532
+ out["images"] = images if images
533
+ Redact.deep(out, viewer)
534
+ end
535
+
536
+ # A message's or a turn's images as a participant sees them: the
537
+ # viewer's own as refs ({file, name, width, height}: what the page's
538
+ # thumbnails load, the files they uploaded), only refs to a stored
539
+ # image (ImageStore::REF_RE); anyone else's as a count. Never source,
540
+ # bytes or mime. nil for none.
541
+ def image_refs(images, author, viewer)
542
+ return nil unless images.is_a?(Array) && !images.empty?
543
+ return images.size unless viewer.own?(author)
544
+
545
+ refs = images.filter_map do |ref|
546
+ file = field(ref, :file)
547
+ next unless file.is_a?(String) && ImageStore::REF_RE.match?(file)
548
+
549
+ out = { "file" => file, "name" => File.basename(field(ref, :name).to_s) }
550
+ %i[width height].each { |key| out[key.to_s] = field(ref, key) if field(ref, key).is_a?(Integer) }
551
+ out
552
+ end
553
+ refs.empty? ? nil : refs
554
+ end
555
+
556
+ DELIVERIES = %w[queue next_step].freeze
557
+
558
+ # A turn record's fields a participant reads: its id and times (the
559
+ # turn time) and its speed. Never its cost, tokens, model, author,
560
+ # cancel reason or failure.
561
+ TURN_RECORD_KEYS = %w[id started_at completed_at duration_ms decode_tps tps_source].freeze
562
+ # The running turn's (SessionMetrics#active_turn_snapshot).
563
+ ACTIVE_TURN_KEYS = %w[id started_at duration_ms].freeze
564
+
565
+ # The show's timing (SessionShow#timing_from) as a participant may
566
+ # read it: the session's clock, each turn's duration and speed, the
567
+ # running turn's start, and the context's fill (its token counts).
568
+ # Never tool_records (guardrail judgment ids, System 1 labels),
569
+ # active_tools, tokens, model_segments (cost per model), memory_index
570
+ # (memory names) or side (side requests' cost).
571
+ def timing(payload)
572
+ return nil unless payload.is_a?(Hash)
573
+
574
+ records = Array(field(payload, :turn_records)).filter_map { |record| pick(record, TURN_RECORD_KEYS) }
575
+ context = field(payload, :context)
576
+ {
577
+ "started_at" => string_or_nil(field(payload, :started_at)),
578
+ "last_activity_at" => string_or_nil(field(payload, :last_activity_at)),
579
+ "session_duration_ms" => number_or_nil(field(payload, :session_duration_ms)),
580
+ "turn_records" => records,
581
+ "tool_records" => [],
582
+ "turn_count" => number_or_nil(field(payload, :turn_count)),
583
+ "active_turn" => pick(field(payload, :active_turn), ACTIVE_TURN_KEYS),
584
+ "active_tools" => [],
585
+ "context" => pick(context, %w[used_tokens window_tokens]),
586
+ "tokens" => nil,
587
+ "model_segments" => [],
588
+ "memory_index" => nil,
589
+ "side" => nil
590
+ }
591
+ end
592
+
593
+ # +keys+ of +hash+ whose values are plain (a string or a number), nil
594
+ # for a hash with none.
595
+ def pick(hash, keys)
596
+ return nil unless hash.is_a?(Hash)
597
+
598
+ out = keys.each_with_object({}) do |key, kept|
599
+ value = field(hash, key.to_sym)
600
+ kept[key] = value if value.is_a?(String) || (value.is_a?(Numeric) && value.to_f.finite?)
601
+ end
602
+ out.empty? ? nil : out
603
+ end
604
+
605
+ def string_or_nil(value) = value.is_a?(String) ? value : nil
606
+
607
+ def number_or_nil(value) = value.is_a?(Numeric) && value.to_f.finite? ? value : nil
608
+
310
609
  # A tool row: the tool's name, and for a file tool the path relative
311
610
  # to the session's folder when it is inside it. Never its arguments.
611
+ # The row's +ref+ is the owner's shape (FileRef#to_h: kind, path, the
612
+ # lines) with the relative path, for the main page's refs.js.
312
613
  def tool_row(tool, ref, viewer)
313
614
  row = { "tool" => tool.to_s }
314
615
  path = ref.is_a?(Hash) ? field(ref, :path) : nil
315
616
  relative = project_path(path, viewer.cwd, home: viewer.home)
316
- row["path"] = relative if relative
317
- row
617
+ return row unless relative
618
+
619
+ file = { "kind" => FileRef::KIND, "path" => relative }
620
+ %i[line end_line].each do |key|
621
+ value = field(ref, key)
622
+ file[key.to_s] = value if value.is_a?(Integer) && value.positive?
623
+ end
624
+ row.merge("ref" => file)
318
625
  end
319
626
 
320
627
  # +path+ relative to +cwd+ when it is inside it, else nil. None when
@@ -1,6 +1,5 @@
1
1
  /* The selection bubble (annotate_bar.js): Annotate + the presets (web.annotate_presets), Copy link last where the page
2
- has one, small pills with a gap (they wrap on a narrow page). Both pages link it: the owner's (app.js) and the
3
- participant page (participant.js). Colours are tokens.css's. */
2
+ has one, small pills with a gap (they wrap on a narrow page). index.html links it (app.js makes the bar). Colours are tokens.css's. */
4
3
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
5
4
  #annotateBar[hidden]{display:none}
6
5
  #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
@@ -47,8 +47,7 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
47
47
  // bar docks at the bottom of the window
48
48
  // @param source (node) => where a selection starting at +node+ came from,
49
49
  // {kind, root, …}, or null where the page isn't annotatable
50
- // (annotations.js annotationSource by default; the participant page
51
- // passes its own, participant_annotate.js)
50
+ // (annotations.js annotationSource by default)
52
51
  // @param quote (text, source) => the quote block for the selected +text+
53
52
  // (annotations.js quoteBlock by default), or null for no text
54
53
  // @param link (source, range) => the selection's link, {url, title}, or