samagotchi 0.155.0 → 0.157.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 (234) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +2 -1
  4. data/docs/architecture.md +22 -13
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/anthropic.md +1 -1
  11. data/docs/internals/attached-context.md +3 -2
  12. data/docs/internals/background-tasks.md +1 -1
  13. data/docs/internals/bridge.md +83 -6
  14. data/docs/internals/bundles.md +28 -18
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +641 -937
  17. data/docs/internals/llm-context-forget.md +8 -3
  18. data/docs/internals/objectives.md +2 -2
  19. data/docs/internals/participants.md +3 -2
  20. data/docs/internals/prompt-caching.md +4 -1
  21. data/docs/internals/remote-sessions.md +8 -3
  22. data/docs/internals/web-frontend.md +3 -2
  23. data/docs/internals/working-set.md +2 -2
  24. data/docs/memory.md +13 -2
  25. data/docs/plugins.md +35 -2
  26. data/docs/sessions.md +11 -11
  27. data/docs/sub-agent.md +17 -2
  28. data/docs/system-one.md +3 -2
  29. data/docs/testing.md +53 -2
  30. data/lib/samagotchi/answer_command.rb +2 -1
  31. data/lib/samagotchi/archive_store.rb +4 -3
  32. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  33. data/lib/samagotchi/bootstrap_command.rb +3 -2
  34. data/lib/samagotchi/bridge/card_places.rb +10 -4
  35. data/lib/samagotchi/bridge/card_store.rb +3 -0
  36. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  37. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  38. data/lib/samagotchi/bridge.rb +31 -6
  39. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  40. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  41. data/lib/samagotchi/broadcast_command.rb +3 -3
  42. data/lib/samagotchi/bundle_command/check.rb +18 -0
  43. data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
  44. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  45. data/lib/samagotchi/bundle_command.rb +5 -2
  46. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  47. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  48. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  49. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  50. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  51. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  52. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  53. data/lib/samagotchi/child_move.rb +13 -57
  54. data/lib/samagotchi/child_reports.rb +2 -2
  55. data/lib/samagotchi/children_status.rb +15 -21
  56. data/lib/samagotchi/cli/parent_wait.rb +9 -23
  57. data/lib/samagotchi/config.rb +20 -112
  58. data/lib/samagotchi/context_command.rb +1 -2
  59. data/lib/samagotchi/context_sources.rb +5 -4
  60. data/lib/samagotchi/context_window.rb +9 -12
  61. data/lib/samagotchi/desktop/macos.rb +1 -1
  62. data/lib/samagotchi/effective_model.rb +57 -22
  63. data/lib/samagotchi/engine/context_judge.rb +19 -11
  64. data/lib/samagotchi/engine/messages.rb +1 -1
  65. data/lib/samagotchi/engine/models.rb +5 -2
  66. data/lib/samagotchi/engine/objectives.rb +29 -11
  67. data/lib/samagotchi/engine/plugins.rb +1 -1
  68. data/lib/samagotchi/engine/recap.rb +8 -4
  69. data/lib/samagotchi/engine/side_requests.rb +21 -11
  70. data/lib/samagotchi/engine/toolkit.rb +10 -9
  71. data/lib/samagotchi/engine/turn_end.rb +1 -1
  72. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  73. data/lib/samagotchi/engine/verify.rb +1 -1
  74. data/lib/samagotchi/engine.rb +43 -12
  75. data/lib/samagotchi/events.rb +3 -2
  76. data/lib/samagotchi/extension_load.rb +6 -3
  77. data/lib/samagotchi/guardrails/targets.rb +24 -9
  78. data/lib/samagotchi/guardrails_command.rb +1 -1
  79. data/lib/samagotchi/host_address.rb +52 -0
  80. data/lib/samagotchi/host_config.rb +72 -1
  81. data/lib/samagotchi/host_model.rb +2 -6
  82. data/lib/samagotchi/host_registry.rb +58 -46
  83. data/lib/samagotchi/kernel_loop.rb +53 -124
  84. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  85. data/lib/samagotchi/llm/chat_loop.rb +40 -21
  86. data/lib/samagotchi/llm/native_backend.rb +1 -1
  87. data/lib/samagotchi/llm_context_apply.rb +54 -1
  88. data/lib/samagotchi/llm_context_notice.rb +1 -1
  89. data/lib/samagotchi/llm_context_strategy.rb +29 -67
  90. data/lib/samagotchi/llm_context_view.rb +9 -0
  91. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  92. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  93. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  94. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  95. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  96. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  97. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  98. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  99. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  100. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  101. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  102. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  103. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  104. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  105. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  106. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  107. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  108. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  109. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  110. data/lib/samagotchi/memory_usage.rb +1 -1
  111. data/lib/samagotchi/model_lookup.rb +75 -0
  112. data/lib/samagotchi/model_price.rb +4 -10
  113. data/lib/samagotchi/model_profile.rb +7 -6
  114. data/lib/samagotchi/model_settings.rb +37 -2
  115. data/lib/samagotchi/models_command.rb +1 -1
  116. data/lib/samagotchi/note_command.rb +2 -3
  117. data/lib/samagotchi/objectives_audit.rb +4 -3
  118. data/lib/samagotchi/participant_mode.rb +2 -1
  119. data/lib/samagotchi/participants.rb +2 -2
  120. data/lib/samagotchi/plugin/api.rb +41 -6
  121. data/lib/samagotchi/plugin/context.rb +1 -1
  122. data/lib/samagotchi/plugin/loader.rb +33 -10
  123. data/lib/samagotchi/plugin/sessions.rb +3 -6
  124. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  125. data/lib/samagotchi/project_env.rb +1 -1
  126. data/lib/samagotchi/reasoning_history.rb +14 -20
  127. data/lib/samagotchi/recap_place.rb +27 -0
  128. data/lib/samagotchi/reminder_carry.rb +1 -1
  129. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  130. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  131. data/lib/samagotchi/reply_wait/target.rb +113 -0
  132. data/lib/samagotchi/reply_wait.rb +22 -2
  133. data/lib/samagotchi/reprefill_meter.rb +45 -0
  134. data/lib/samagotchi/review_command.rb +1 -1
  135. data/lib/samagotchi/sampling_settings.rb +9 -16
  136. data/lib/samagotchi/self_report.rb +36 -37
  137. data/lib/samagotchi/send_command.rb +17 -23
  138. data/lib/samagotchi/session.rb +370 -445
  139. data/lib/samagotchi/session_access/local.rb +12 -11
  140. data/lib/samagotchi/session_access/remote.rb +22 -6
  141. data/lib/samagotchi/session_archive_command.rb +2 -2
  142. data/lib/samagotchi/session_chain.rb +7 -6
  143. data/lib/samagotchi/session_continue.rb +6 -5
  144. data/lib/samagotchi/session_delete_command.rb +1 -1
  145. data/lib/samagotchi/session_field.rb +133 -0
  146. data/lib/samagotchi/session_inbox.rb +49 -1
  147. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  148. data/lib/samagotchi/session_manager/emptiness.rb +139 -0
  149. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  150. data/lib/samagotchi/session_manager/listing.rb +118 -0
  151. data/lib/samagotchi/session_manager/removal.rb +190 -0
  152. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  153. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  154. data/lib/samagotchi/session_manager.rb +28 -1541
  155. data/lib/samagotchi/session_metrics.rb +8 -5
  156. data/lib/samagotchi/session_paths.rb +57 -0
  157. data/lib/samagotchi/session_retention.rb +2 -2
  158. data/lib/samagotchi/session_row.rb +200 -0
  159. data/lib/samagotchi/session_setup.rb +15 -12
  160. data/lib/samagotchi/sessions_command.rb +11 -129
  161. data/lib/samagotchi/share_command.rb +1 -1
  162. data/lib/samagotchi/starting_marker.rb +65 -0
  163. data/lib/samagotchi/steer_cut_after.rb +7 -14
  164. data/lib/samagotchi/system_one/recorder.rb +49 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  166. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  167. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  168. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  169. data/lib/samagotchi/terminal_ui.rb +5 -7
  170. data/lib/samagotchi/testing/install.rb +2 -1
  171. data/lib/samagotchi/testing/sessions.rb +1 -1
  172. data/lib/samagotchi/thinking.rb +16 -21
  173. data/lib/samagotchi/token_usage.rb +1 -1
  174. data/lib/samagotchi/tool_activity.rb +10 -4
  175. data/lib/samagotchi/tool_declarations.rb +2 -2
  176. data/lib/samagotchi/tool_output_cut.rb +17 -0
  177. data/lib/samagotchi/tool_response.rb +5 -3
  178. data/lib/samagotchi/tool_runner.rb +25 -5
  179. data/lib/samagotchi/toolkit_command.rb +1 -1
  180. data/lib/samagotchi/tools/delegate.rb +55 -45
  181. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  182. data/lib/samagotchi/tools/delegate_result.rb +5 -5
  183. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  184. data/lib/samagotchi/tools/index_hint.rb +14 -0
  185. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  186. data/lib/samagotchi/tools/registry.rb +28 -11
  187. data/lib/samagotchi/tools/send_note.rb +4 -6
  188. data/lib/samagotchi/tools/tool_index.rb +52 -51
  189. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  190. data/lib/samagotchi/turn_context.rb +2 -1
  191. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  192. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  193. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  194. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  195. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  196. data/lib/samagotchi/update_command.rb +11 -7
  197. data/lib/samagotchi/verify_fresh.rb +7 -11
  198. data/lib/samagotchi/version.rb +1 -1
  199. data/lib/samagotchi/vision_support.rb +11 -14
  200. data/lib/samagotchi/web/app.rb +45 -28
  201. data/lib/samagotchi/web/message_display.rb +57 -7
  202. data/lib/samagotchi/web/participant_access.rb +3 -3
  203. data/lib/samagotchi/web/public/activity.js +16 -7
  204. data/lib/samagotchi/web/public/app.js +29 -8
  205. data/lib/samagotchi/web/public/bubbles.js +8 -3
  206. data/lib/samagotchi/web/public/cards_place.js +15 -0
  207. data/lib/samagotchi/web/public/commands.js +5 -1
  208. data/lib/samagotchi/web/public/data.js +4 -2
  209. data/lib/samagotchi/web/public/history_shape.js +220 -0
  210. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  211. data/lib/samagotchi/web/public/reload_check.js +71 -0
  212. data/lib/samagotchi/web/public/stage_model.js +7 -5
  213. data/lib/samagotchi/web/public/stage_view.js +7 -3
  214. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  215. data/lib/samagotchi/web/public/timing.js +43 -2
  216. data/lib/samagotchi/web/public/turn_block.js +215 -30
  217. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  218. data/lib/samagotchi/web/public/turn_events.js +10 -1
  219. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  220. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  221. data/lib/samagotchi/web/server.rb +4 -2
  222. data/lib/samagotchi/web/session_access_routes.rb +6 -6
  223. data/lib/samagotchi/web/session_control.rb +4 -4
  224. data/lib/samagotchi/web/session_create.rb +3 -3
  225. data/lib/samagotchi/web/session_hub.rb +9 -17
  226. data/lib/samagotchi/web/session_show.rb +67 -5
  227. data/lib/samagotchi/web/session_summary.rb +39 -28
  228. data/lib/samagotchi/worker.rb +83 -32
  229. data/lib/samagotchi/worker_boot.rb +184 -0
  230. data/lib/samagotchi/worker_link.rb +3 -0
  231. data/lib/samagotchi/worker_signals.rb +1 -1
  232. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  233. metadata +32 -4
  234. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -27,6 +27,7 @@ require_relative "../session_metrics"
27
27
  require_relative "../session_commands"
28
28
  require_relative "../steer"
29
29
  require_relative "../host_registry"
30
+ require_relative "../effective_model"
30
31
  require_relative "../llm_context_override"
31
32
  require_relative "../working_set"
32
33
  require_relative "../model_catalog"
@@ -75,6 +76,7 @@ require_relative "session_show"
75
76
  require_relative "session_create"
76
77
  require_relative "session_control"
77
78
  require_relative "verify_fresh_routes"
79
+ require_relative "reload_check_route"
78
80
  require_relative "push_routes"
79
81
  require_relative "block_renderer_routes"
80
82
  require_relative "update_check"
@@ -109,6 +111,7 @@ module Samagotchi
109
111
  include SessionCreate
110
112
  include SessionControl
111
113
  include VerifyFreshRoutes
114
+ include ReloadCheckRoute
112
115
  include PushRoutes
113
116
  include BlockRendererRoutes
114
117
  include ProxyGate
@@ -146,6 +149,9 @@ module Samagotchi
146
149
  # freshly-spawned worker's bridge before answering POST /api/sessions.
147
150
  # @param working_set [Boolean] the stage view's working set
148
151
  # (web.working_set); ?working_set=1|0 overrides it for one page load
152
+ # @param reload_check [Boolean] the page checks each finished turn
153
+ # against a reload of it and posts the counts (web.reload_check,
154
+ # opt-in; POST /api/sessions/:id/reload_check logs them)
149
155
  # @param annotate_presets [String, Array] the quick replies next to
150
156
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
151
157
  # page parses them (annotate_presets.js).
@@ -184,6 +190,7 @@ module Samagotchi
184
190
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
185
191
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
186
192
  working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
193
+ reload_check: Config::BY_KEY["web.reload_check"].default,
187
194
  grants: nil, public_url: nil, public_token: nil, push: nil, start_dir: nil, update_runner: nil,
188
195
  update_blocked: nil, update_ops: nil)
189
196
  @start_dir = start_dir
@@ -199,6 +206,8 @@ module Samagotchi
199
206
  @bridge_wait_timeout = bridge_wait_timeout
200
207
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
201
208
  @working_set = working_set
209
+ @reload_check = reload_check
210
+ @reload_checks = ReloadCheckRoute::Seen.new
202
211
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
203
212
  @editor_url = Editor.template(editor)
204
213
  @hub = hub
@@ -292,6 +301,7 @@ module Samagotchi
292
301
  ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
293
302
  ["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
294
303
  ["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
304
+ ["POST", %r{\A/api/sessions/([^/]+)/reload_check\z}, :handle_reload_check, :owner, :session],
295
305
  ["POST", %r{\A/api/sessions/([^/]+)/guardrails/wrong\z}, :handle_system_one_wrong, :owner, :session],
296
306
  ["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
297
307
  ["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
@@ -353,10 +363,10 @@ module Samagotchi
353
363
  # "..", a %2F) is an unknown session before any handler makes a path.
354
364
  # (/api/sessions/resolve and /summaries take no id.)
355
365
  if req.path_info.start_with?("/api/sessions/") && !m.captures.empty? && !Session.valid_id?(m.captures.first)
356
- return error_response(404, "not_found", "Session not found: #{m.captures.first.to_s[0, 80]}")
366
+ return session_not_found(m.captures.first)
357
367
  end
358
368
 
359
- return session_route(principal) { send(handler, req, *m.captures) } if kind == :session
369
+ return session_route(principal, m.captures.first) { send(handler, req, *m.captures) } if kind == :session
360
370
 
361
371
  return send(handler, req, *m.captures)
362
372
  end
@@ -383,11 +393,14 @@ module Samagotchi
383
393
  # raises (Session.load, the manager's find) is a 404 not_found with
384
394
  # its message. Only these routes: elsewhere an ArgumentError is a bug,
385
395
  # #route's 500. A handler's own rescues run first.
386
- def session_route(principal = nil)
396
+ def session_route(principal = nil, id = nil)
387
397
  yield
388
398
  rescue ArgumentError => e
389
- # A participant's 404 never carries the lookup's words (a path).
390
- error_response(404, "not_found", principal.nil? || principal.owner? ? e.message : "Session not found")
399
+ return error_response(404, "not_found", e.message) if principal.nil? || principal.owner?
400
+
401
+ # A participant's 404 never carries a lookup's words (a path): it
402
+ # names their own session's id (valid_id? passed), nothing else.
403
+ session_not_found(id)
391
404
  end
392
405
 
393
406
  # API requests at debug level (not the page and its assets): method,
@@ -1152,32 +1165,31 @@ module Samagotchi
1152
1165
  Degrade.call(:web, "model_sampling_unreadable") do
1153
1166
  return if registry.nil? || models.empty?
1154
1167
 
1155
- settings = ConfigFile.model_settings
1168
+ settings = ModelSettings.all
1156
1169
  models.each do |model|
1157
- target = registry.resolve(model[:name])
1158
- names = registry.lookup_names(model[:name], target: target)
1159
- summary = SamplingSettings.summary(target, names: names, models: settings)
1170
+ effective = EffectiveModel.of(registry, ModelChoice.new(name: model[:name], typed: model[:name]), models: settings)
1171
+ summary = SamplingSettings.summary(effective.lookup)
1160
1172
  model[:sampling] = summary if summary
1161
- add_llm_context(model, target, names, settings)
1162
- add_thinking(model, target, names, settings)
1173
+ add_llm_context(model, effective)
1174
+ add_thinking(model, effective)
1163
1175
  end
1164
1176
  end
1165
1177
  end
1166
1178
 
1167
1179
  # The LLM context +model+ starts under (the start page's ⚙ panel);
1168
- # one that can't be read leaves this model without, not the others.
1169
- def add_llm_context(model, target, names, settings)
1170
- Degrade.call(:web, "model_llm_context_unreadable") do
1171
- model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
1172
- end
1180
+ # one that can't be read leaves this model without (the key absent),
1181
+ # not the others.
1182
+ def add_llm_context(model, effective)
1183
+ explained = effective.llm_context_explained({})
1184
+ model[:llm_context] = explained.summary(nil) if explained
1173
1185
  end
1174
1186
 
1175
1187
  # The thinking level +model+ starts at, with where it came from (the
1176
- # start page's ⚙ panel; SettingExplained#summary, no session).
1177
- def add_thinking(model, target, names, settings)
1178
- Degrade.call(:web, "model_thinking_unreadable") do
1179
- model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
1180
- end
1188
+ # start page's ⚙ panel; SettingExplained#summary, no session); one
1189
+ # that can't be read leaves this model without (the key absent).
1190
+ def add_thinking(model, effective)
1191
+ explained = effective.thinking_explained(own: nil)
1192
+ model[:thinking] = explained.summary if explained
1181
1193
  end
1182
1194
 
1183
1195
  # The served model a session's last generation reported (analytics.json,
@@ -1207,17 +1219,16 @@ module Samagotchi
1207
1219
  end
1208
1220
  end
1209
1221
 
1210
- # Built again when config.yml's hosts: changed (compared as read: the
1211
- # YAML read is mtime-cached), so the model picker lists a host added
1222
+ # The shared registry (HostRegistry.current: built again when
1223
+ # config.yml's hosts: changed), so the model picker lists a host added
1212
1224
  # while chi web runs. An injected registry (specs) stays.
1213
1225
  def host_registry
1214
1226
  @models_mutex.synchronize do
1215
1227
  next @registry if @registry_given
1216
1228
 
1217
- hosts = ConfigFile.hosts_config
1218
- if @registry.nil? || hosts != @registry_hosts
1219
- @registry = HostRegistry.new(hosts_config: hosts)
1220
- @registry_hosts = hosts
1229
+ current = HostRegistry.current
1230
+ unless current.equal?(@registry)
1231
+ @registry = current
1221
1232
  @models_thread = nil # a listing of the old hosts
1222
1233
  end
1223
1234
  @registry
@@ -1560,7 +1571,7 @@ module Samagotchi
1560
1571
  begin
1561
1572
  session = @session_class.load(id, state_dir: default_state_dir)
1562
1573
  rescue ArgumentError
1563
- return error_response(404, "not_found", "Session not found: #{id}")
1574
+ return session_not_found(id)
1564
1575
  end
1565
1576
  # Its terminal's alone: a tab's stream would keep its worker up.
1566
1577
  return scratch_refused(id) if session.scratch
@@ -1902,6 +1913,8 @@ module Samagotchi
1902
1913
  attrs["home"] = Dir.home
1903
1914
  # Only when on: the page reads its presence.
1904
1915
  attrs["working-set"] = "1" if working_set?(req.params["working_set"])
1916
+ # The opt-in reload check (reload_check.js): only when on.
1917
+ attrs["reload-check"] = "1" if @reload_check
1905
1918
  attrs.merge!(annotate_presets_data)
1906
1919
  dir, = scope_dir(req.params["dir"])
1907
1920
  unless dir
@@ -1976,6 +1989,10 @@ module Samagotchi
1976
1989
  error_response(404, "not_found", "not found: #{path}")
1977
1990
  end
1978
1991
 
1992
+ # 404 not_found for an id (or prefix) that names no session, in
1993
+ # Session::NotFound's words (SessionAccess::Remote maps it back).
1994
+ def session_not_found(given) = error_response(404, "not_found", Session::NotFound.new(given).message)
1995
+
1979
1996
  # One tab's GET /api/events: a bounded queue behind a lambda sink,
1980
1997
  # subscribed and snapshotted as one step under the hub's lock, then
1981
1998
  # drained onto the socket. The loop ends when the hub stops or the
@@ -9,12 +9,14 @@ require_relative "../message_parts"
9
9
  require_relative "../message_time"
10
10
  require_relative "../objectives"
11
11
  require_relative "../output_formatter"
12
+ require_relative "../recap_place"
12
13
  require_relative "../recap_store"
13
14
  require_relative "../ref"
14
15
  require_relative "../revise_anchor"
15
16
  require_relative "../steer"
16
17
  require_relative "../turn_note"
17
18
  require_relative "../bridge/card_store"
19
+ require_relative "../bridge/turn_drawings"
18
20
 
19
21
  module Samagotchi
20
22
  module Web
@@ -47,20 +49,26 @@ module Samagotchi
47
49
  # a live worker's (it counts against its Engine's messages), else
48
50
  # recap.json against the session file. A stopped session gets its
49
51
  # recap without a worker being woken.
50
- # @return [Hash, nil] {text:, turns_since:, created_at:} (created_at:
51
- # when it was written, UTC ISO; absent from an older recap)
52
+ # The last turn it covers names its place (opening_id, RecapPlace);
53
+ # an older worker's saved_recap has none: the page counts turns_since.
54
+ # @return [Hash, nil] {text:, turns_since:, opening_id:, created_at:}
55
+ # (created_at: when it was written, UTC ISO; absent from an older
56
+ # recap)
52
57
  def saved_recap_for(id, session, turn_snapshot)
53
58
  if turn_snapshot
54
59
  saved = turn_snapshot["saved_recap"]
55
- return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, created_at: saved["created_at"] }.compact
60
+ return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, opening_id: saved["opening_id"],
61
+ created_at: saved["created_at"] }.compact
56
62
  end
57
63
 
58
64
  saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
59
65
  return nil unless saved
60
66
 
61
67
  messages = Array(session.messages)
62
- since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_opening?(m) }
63
- { text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
68
+ covered = saved[:covered].to_i
69
+ since = messages.drop(covered).count { |m| Samagotchi::Steer.turn_opening?(m) }
70
+ { text: saved[:text], turns_since: since, opening_id: RecapPlace.opening_id(messages, covered),
71
+ created_at: saved[:created_at] }.compact
64
72
  rescue StandardError
65
73
  nil
66
74
  end
@@ -91,14 +99,22 @@ module Samagotchi
91
99
  # @param markdown [Boolean] false: no html for a delegate report or an
92
100
  # answer (the participant review drops both and renders its replies
93
101
  # from their redacted text, ParticipantReview#with_reply_html)
94
- def messages_for_display(msgs, parts: false, cwd: nil, markdown: true)
102
+ # @param drawings [Array<Bridge::TurnDrawings::Line>] the valid lines
103
+ # (TurnDrawings.valid against +msgs+): each item of a kind a drawing
104
+ # draws (#drawn_kind?) from a line's range gets `drawn: <turn_id>`
105
+ # @param strip_drawn [Boolean] a stamped item's parts go (the drawing
106
+ # has them); it keeps its ✂ marks as `marks: {tool_id => edit}`
107
+ def messages_for_display(msgs, parts: false, cwd: nil, markdown: true, drawings: [], strip_drawn: false)
95
108
  filtered = []
96
109
  list = Array(msgs)
110
+ # Each item's message index, for the drawings' stamps.
111
+ origins = []
97
112
  # The outputs' ✂ marks (LLM context edits), by id, for the parts.
98
113
  marks = parts ? MessageParts.edit_marks(list) : {}
99
114
  # The list before each objectives note, for what its entry says changed.
100
115
  objectives = nil
101
116
  list.each_with_index do |m, index|
117
+ origins.fill(index - 1, origins.size...filtered.size)
102
118
  role = (m[:role] || m["role"]).to_s
103
119
  content = (m[:content] || m["content"]).to_s
104
120
  if Samagotchi::ContextNote.note?(m)
@@ -205,11 +221,45 @@ module Samagotchi
205
221
  message[:parts] = did if did
206
222
  filtered << message
207
223
  end
208
- filtered
224
+ origins.fill(list.size - 1, origins.size...filtered.size)
225
+ stamp_drawn(filtered, origins, Array(drawings), strip: strip_drawn)
209
226
  rescue StandardError
210
227
  []
211
228
  end
212
229
 
230
+ # +items+ with a drawn turn's stamp on each item of a kind its drawing
231
+ # draws (the opening, steps, steers, merged lines, the empty answer):
232
+ # a context note, a `!cmd`'s record and anything else keep today's
233
+ # place. +origins+: each item's message index.
234
+ def stamp_drawn(items, origins, drawings, strip:)
235
+ return items if drawings.empty?
236
+
237
+ items.each_with_index.map do |item, i|
238
+ line = drawings.find { |l| l.range.cover?(origins[i]) }
239
+ next item unless line && drawn_kind?(item)
240
+
241
+ stamped = item.merge(drawn: line.turn_id)
242
+ strip ? without_parts(stamped) : stamped
243
+ end
244
+ end
245
+
246
+ def drawn_kind?(item)
247
+ case item[:role]
248
+ when "note" then item[:turn_start] == true
249
+ when "user" then !(item[:turn_id].nil? && item[:content].match?(/\A!\(.*\)\n/))
250
+ else %w[assistant steer empty_answer].include?(item[:role])
251
+ end
252
+ end
253
+
254
+ # A drawn item without its parts: its ✂ marks by output id stay (an
255
+ # overlay on the drawing's rows: later state, as a card's latest body).
256
+ def without_parts(item)
257
+ tools = Array(item.dig(:parts, :tools))
258
+ marks = tools.filter_map { |tool| [tool[:tool_id], tool[:edit]] if tool[:tool_id] && tool[:edit] }.to_h
259
+ out = item.except(:parts)
260
+ marks.empty? ? out : out.merge(marks: marks)
261
+ end
262
+
213
263
  # A turn opening's Revise stamp as {revises: {turn_id:}}, checked again
214
264
  # (a stored file is never trusted), or nothing.
215
265
  def revises_of(message)
@@ -185,7 +185,7 @@ module Samagotchi
185
185
  end
186
186
 
187
187
  def unknown_session(id)
188
- error_response(404, "not_found", "Session not found: #{id.to_s[0, 80]}")
188
+ session_not_found(id)
189
189
  end
190
190
 
191
191
  # 403 not_permitted for a participant (the owner is never refused by
@@ -388,14 +388,14 @@ module Samagotchi
388
388
  def participant_resolve(grant, given)
389
389
  return json_response(200, { id: grant.session_id }) if grant.session_id.start_with?(given)
390
390
 
391
- error_response(404, "not_found", "no session #{given[0, 80]}")
391
+ session_not_found(given)
392
392
  end
393
393
 
394
394
  # POST …/wait/prepare for a participant (an agent's chi --remote
395
395
  # send --wait, D11): only for a turn of theirs (+turn+, the enqueued
396
396
  # id their POST …/turn answered); the body's busy, queue and created
397
397
  # don't count. The answer is what their wait on that turn needs
398
- # (SendCommand's WaitTarget.after with the turn asked, the wait's
398
+ # (ReplyWait::Target.wait_only with the turn asked, the wait's
399
399
  # baseline), never the owner's wait state: the last turn's end (the
400
400
  # baseline's last_turn), whether their turn is known, whether a worker
401
401
  # runs it, and last_turn_id only when it is their turn. No cursor
@@ -50,8 +50,11 @@ export function newActivity() {
50
50
  // refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
51
51
  // label?, actions}]): the first on the row's title, the others in its
52
52
  // menu (refs.js bindRowRefs).
53
+ // tool_id: the id the call's output is saved under (the server's ToolIds,
54
+ // "t41"; not a row field there), so a row names the output a reload's
55
+ // part and a strategy's edit name.
53
56
  export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as", "via"]);
54
- export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
57
+ export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs", "tool_id"]);
55
58
  const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
56
59
 
57
60
  // The +fields+ of +source+ that are set, as an object (for a spread).
@@ -115,10 +118,10 @@ function markStart(row, event, now) {
115
118
 
116
119
  // Update (or synthesize) a row from a :tool_call_completed event. Returns the row.
117
120
  // If no matching :tool_call_started was seen (e.g. a replay gap), a row is
118
- // created on the fly so the completion is never lost. Its duration
119
- // (duration_ms, as a reloaded row has it from the tool record): a
120
- // snapshot's own, else from the start the page saw to +now+, less the time
121
- // the call waited for an approval (waited_ms), as the worker times it.
121
+ // created on the fly so the completion is never lost. Its duration: the
122
+ // event's duration_ms (the worker's, the figure a reload's tool record and
123
+ // the terminal show), else (an older worker) from the start the page saw to
124
+ // +now+, less the time the call waited for an approval (waited_ms).
122
125
  export function addCompleted(model, event = {}, now = null) {
123
126
  const iteration = event.iteration ?? 0;
124
127
  const callIndex = event.call_index ?? 0;
@@ -137,8 +140,10 @@ export function addCompleted(model, event = {}, now = null) {
137
140
  row = synthesized.row;
138
141
  }
139
142
  row.status = completedStatus(event.activity?.status);
140
- row.output = event.output || "";
141
- row.output_truncated = !!event.output_truncated;
143
+ // A long output's web cut (the worker's preview: the text a reload's row
144
+ // has from the saved output), else the output itself.
145
+ row.output = event.preview ?? (event.output || "");
146
+ row.output_truncated = event.preview != null || !!event.output_truncated;
142
147
  if (event.activity?.params) row.params = event.activity.params;
143
148
  // The completed event carries the title (ACTIVITY_FIELDS) in its activity.
144
149
  Object.assign(row, pickFields(event.activity, ACTIVITY_FIELDS));
@@ -160,6 +165,10 @@ export function addCompleted(model, event = {}, now = null) {
160
165
  // What an edit/write changed in its file (a reload draws it from the
161
166
  // saved parts, turn_html.js toolRowTemplate).
162
167
  if (event.diff) row.diff = event.diff;
168
+ // An LLM context edit's ✂ mark on its output, later state a drawn turn's
169
+ // row takes from its saved item (turn_block.js drawnTurnNodes); no live
170
+ // event carries one.
171
+ if (event.edit) row.edit = event.edit;
163
172
  const duration = callDuration(row, event, now);
164
173
  if (duration !== null) row.duration_ms = duration;
165
174
  return row;
@@ -53,6 +53,7 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
53
53
  import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
54
54
  import { createCardsPlace } from "./cards_place.js";
55
55
  import { createStageView } from "./stage_view.js";
56
+ import { createReloadCheck } from "./reload_check.js";
56
57
  import { createWorkingSet, groupItems } from "./working_set_view.js";
57
58
  import { createObjectives } from "./objectives_wiring.js";
58
59
  import { turnGroups } from "./timing.js";
@@ -356,6 +357,17 @@ const turnDom = createStageView({
356
357
  workingSet,
357
358
  // Stop turn in its status row (disabled until the request settles).
358
359
  onCancel: () => handleCancel(),
360
+ onHandedOff: (handed) => reloadCheck.handedOff(handed),
361
+ });
362
+ // The opt-in reload check (web.reload_check): a handed-off turn against a
363
+ // reload of it, the counts to chi web (reload_check.js).
364
+ const reloadCheck = createReloadCheck({
365
+ enabled: () => document.body.dataset.reloadCheck === "1",
366
+ sessionId: () => store.selected,
367
+ running: () => liveTurn.running,
368
+ read: (id) => getSession(id, { parts: true }),
369
+ render: (data) => turnHistoryNodes(data.messages || [], normalizeTiming(data.timing), historyOpts(data)),
370
+ post: (id, body) => api(`/api/sessions/${id}/reload_check`, { method: "POST", body: JSON.stringify(body) }),
359
371
  });
360
372
  // The history's bottom reserve and the toast's place follow the composer
361
373
  // card and the stage floating over the history (dock_reserve.js).
@@ -1001,7 +1013,7 @@ function renderSessionView(data, { kept = [] } = {}) {
1001
1013
  });
1002
1014
  liveTurn.timing.ended = store.current.timing.turnRecords.length;
1003
1015
  const activeTurnId = store.current.timing.activeTurn?.id || null;
1004
- if (data.messages && data.messages.length) renderHistory(data.messages);
1016
+ if (data.messages && data.messages.length) renderHistory(data.messages, data);
1005
1017
  else renderHistory(data.history || []);
1006
1018
  seedWorkingSet(data);
1007
1019
  objectives.seed(data.objectives);
@@ -1029,9 +1041,12 @@ function renderSessionView(data, { kept = [] } = {}) {
1029
1041
  // The recap saved with the session (also a stopped one's), else a live
1030
1042
  // worker's from before saved_recap existed. After the cards: the last
1031
1043
  // turn's check-in notice goes to the end, and the recap closes the page.
1032
- if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
1044
+ const saved = data.saved_recap;
1045
+ if (saved) showRecap(saved.text, saved.turns_since, saved.created_at || null, saved.opening_id || null);
1033
1046
  else showRecap(data.recap);
1034
- const replay = snapshotEvents({
1047
+ // chi web's unfold of the join (TurnAccumulator.join_events); the page's
1048
+ // own only for a mixed cache (an older chi web's payload has none).
1049
+ const replay = data.replay ?? snapshotEvents({
1035
1050
  current_turn: data.current_turn,
1036
1051
  queued: data.queued,
1037
1052
  queued_commands: data.queued_commands,
@@ -1048,7 +1063,8 @@ function renderSessionView(data, { kept = [] } = {}) {
1048
1063
  turnDom.runningWithoutTurn?.(liveTurn.running);
1049
1064
  if (liveTurn.running && store.current.timing.activeTurn?.started_at) liveTurn.timing.start(store.current.timing.activeTurn.started_at, activeTurnId);
1050
1065
  } else if (liveTurn.running) {
1051
- // The replayed turn_started carries no id: the running turn's record's.
1066
+ // The live line's turn: the running turn's record's (the replayed
1067
+ // turn_started carries the same id, on both unfolds).
1052
1068
  liveTurn.timing.turnId = activeTurnId;
1053
1069
  }
1054
1070
  turnDom.flush();
@@ -1079,7 +1095,7 @@ function renderSessionView(data, { kept = [] } = {}) {
1079
1095
  function seedWorkingSet(data) {
1080
1096
  if (!workingSet) return;
1081
1097
  const items = data.messages || [];
1082
- const groups = typeof items[0] === "object" && items[0]?.role ? turnGroups(items, store.current.timing).filter((g) => g.kind === "turn") : [];
1098
+ const groups = typeof items[0] === "object" && items[0]?.role ? turnGroups(items, store.current.timing, { drawings: data.turn_drawings }).filter((g) => g.kind === "turn") : [];
1083
1099
  if (data.current_turn && groups.length) groups.pop();
1084
1100
  workingSet.seed(groups.map((g, i) => {
1085
1101
  const text = String(items[g.user]?.content ?? "").replace(/\s+/g, " ").trim();
@@ -1087,17 +1103,22 @@ function seedWorkingSet(data) {
1087
1103
  }));
1088
1104
  }
1089
1105
 
1090
- function renderHistory(items) {
1106
+ // turnHistoryNodes' options for a read's +data+: its turns' drawings
1107
+ // (turn_drawing.js) and cards (a drawn card's body).
1108
+ const historyOpts = (data = {}) => ({ thumbs: (images) => thumbsTemplate(store.selected, images), workingSet: !!workingSet,
1109
+ drawings: data.turn_drawings || null, cards: data.cards || null, capabilities: pageCapabilities });
1110
+
1111
+ function renderHistory(items, data = {}) {
1091
1112
  gapClock.seed(null);
1092
1113
  if (!items.length) {
1093
1114
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
1094
1115
  return;
1095
1116
  }
1096
1117
  if (typeof items[0] === "object" && items[0].role) {
1097
- historyEl.replaceChildren(turnHistoryNodes(items, store.current.timing, { thumbs: (images) => thumbsTemplate(store.selected, images), workingSet: !!workingSet }));
1118
+ historyEl.replaceChildren(turnHistoryNodes(items, store.current.timing, historyOpts(data)));
1098
1119
  ownerVerify.renderSaved();
1099
1120
  // The live dividers count from the history's last activity.
1100
- gapClock.seed(lastActivity(turnGroups(items, store.current.timing), items));
1121
+ gapClock.seed(lastActivity(turnGroups(items, store.current.timing, { drawings: data.turn_drawings }), items));
1101
1122
  linkTextRefs();
1102
1123
  } else {
1103
1124
  historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
@@ -4,7 +4,8 @@
4
4
  // notices, plugins' steers, command and note bubbles. The markup is lit
5
5
  // (turn_html.js elementOf); createBubbles puts them in the history.
6
6
  import { html, nothing } from "./vendor/lit-html.js";
7
- import { fullTime, normalize, recapLabel, recapPlace } from "./format.js";
7
+ import { fullTime, normalize, recapLabel } from "./format.js";
8
+ import { recapStampPlace } from "./recap_stamp.js";
8
9
  import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
9
10
  import { thumbsTemplate } from "./images.js";
10
11
  import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
@@ -254,14 +255,16 @@ export function createBubbles({
254
255
  // the end of the history. A turn starting leaves it in place, marked
255
256
  // earlier; only a new recap replaces it.
256
257
 
257
- function showRecap(text, turnsSince = 0, createdAt = null) {
258
+ // +openingId+: a saved recap's stamp, the last turn it covers (its place;
259
+ // without one, +turnsSince+ counts it).
260
+ function showRecap(text, turnsSince = 0, createdAt = null, openingId = null) {
258
261
  if (!text) return;
259
262
  removeRecap();
260
263
  const details = elementOf(recapTemplate(text, turnsSince, createdAt));
261
264
  // A stale one sits where it was written, above the turns it doesn't cover.
262
265
  // Never in the stage: a recap is between turns.
263
266
  turnDom.settleFold();
264
- const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
267
+ const prompt = recapStampPlace(turnPromptBubbles(historyEl), openingId, turnsSince);
265
268
  // Above that turn's gap divider too: the recap was written before the quiet stretch ended. The
266
269
  // snapshot's cards placed at that prompt (placed first) sit between the divider and the prompt.
267
270
  // A delegate report's fold row (answer_fold.js) heads its prompt.
@@ -314,6 +317,8 @@ export function createBubbles({
314
317
  // Page-only: kept across a resync of this session (keptCommandBubbles).
315
318
  const id = selected();
316
319
  if (id) div.dataset.keptFor = id;
320
+ // A reminder's: the turn it went into (restoreCommandBubbles).
321
+ if (view.turnId) div.dataset.turnId = view.turnId;
317
322
  drawCommandBubble(div, view);
318
323
  // When it happened (a reminder's at): the hover, as a reload's bubbles.
319
324
  const when = fullTime(view.at);
@@ -92,6 +92,14 @@ export function createCardsPlace({
92
92
  function placeSnapshotCards(entries, { before: end = null } = {}) {
93
93
  const users = turnPromptBubbles(historyEl);
94
94
  for (const entry of entries) {
95
+ // A drawn turn's own card or row is in its drawing, where live drew it
96
+ // (turn_block.js drawnTurnNodes): only its body refreshes, by id. Its
97
+ // question is still placed from here.
98
+ if (drawnEntry(entry)) {
99
+ const el = isCard(entry) ? cardElement(entry.id) : null;
100
+ if (el) refreshCardBody(el, entry);
101
+ continue;
102
+ }
95
103
  const before = snapshotPlace(users, entry) || (entry.after_failed ? null : end);
96
104
  if (isCard(entry)) upsertCard(entry, { before });
97
105
  else if (entry.type === "question") placeResolvedQuestion(entry, before);
@@ -101,6 +109,13 @@ export function createCardsPlace({
101
109
  }
102
110
  }
103
111
 
112
+ // An in-turn entry (not a question) of a turn the history drew from its
113
+ // drawing (its pieces carry data-drawn; the entry's of_turn, CardStore's).
114
+ function drawnEntry(entry) {
115
+ if (!entry?.in_turn || entry.type === "question" || typeof entry.of_turn !== "string") return false;
116
+ return [...historyEl.querySelectorAll("[data-drawn]")].some((el) => el.dataset.drawn === entry.of_turn);
117
+ }
118
+
104
119
  // A finished turn's row, a hook's notice or the loop's asking again
105
120
  // (+before+: the next turn's prompt, or null for the last turn): a row of
106
121
  // its step, above the row of the call it came before, as live; else (a
@@ -268,11 +268,15 @@ export function keptCommandBubbles(historyEl, id) {
268
268
  .filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
269
269
  }
270
270
 
271
- // The kept bubbles back into the redrawn history, each after its turn.
271
+ // The kept bubbles back into the redrawn history, each after its turn; a
272
+ // reminder's whose turn the history now draws from its drawing
273
+ // (data-drawn: the drawing has the reminder) stays out.
272
274
  export function restoreCommandBubbles(historyEl, kept) {
273
275
  if (!kept.length) return;
274
276
  historyEl.querySelector(".hint")?.remove();
277
+ const drawn = new Set([...historyEl.querySelectorAll("[data-drawn]")].map((el) => el.dataset.drawn));
275
278
  kept.forEach(({ el, anchor }) => {
279
+ if (el.dataset.turnId && drawn.has(el.dataset.turnId)) return;
276
280
  const { els, kinds } = historyAnchors(historyEl);
277
281
  const at = anchorInsertIndex(kinds, anchor);
278
282
  if (at < els.length) els[at].before(el);
@@ -130,9 +130,11 @@ export function uploadImage(id, file, name, opts = {}) {
130
130
  // (mergeTiming), and opts.turnId asks for that turn's answer.
131
131
  // opts.timing: the whole timing alone (when the merge came up short).
132
132
  // opts.parts: each saved message with what it did (thinking, tool calls
133
- // and their output), for the turn block's reload.
133
+ // and their output), for the turn block's reload; with it, drawings=1: a
134
+ // finished turn with a drawing comes as its turn_drawings, its items
135
+ // stamped (drawn) without their parts (turn_drawing.js).
134
136
  export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...opts } = {}) {
135
- let query = cards ? "?cards=1" : tail ? "?tail=1" : timing ? "?timing=1" : parts ? "?parts=1" : "";
137
+ let query = cards ? "?cards=1" : tail ? "?tail=1" : timing ? "?timing=1" : parts ? "?parts=1&drawings=1" : "";
136
138
  if (tail && recent) query += "&recent=1";
137
139
  if (tail && turnId) query += `&turn_id=${encodeURIComponent(turnId)}`;
138
140
  return api(`/api/sessions/${id}${query}`, opts);