samagotchi 0.156.0 → 0.158.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -17,6 +17,7 @@ require_relative "../delivery"
17
17
  require_relative "../answer_tail"
18
18
  require_relative "../answer_refs"
19
19
  require_relative "../question_asked"
20
+ require_relative "../project_init"
20
21
  require_relative "../bridge_client"
21
22
  require_relative "../worker_link"
22
23
  require_relative "../bridge/bounded_queue"
@@ -27,6 +28,7 @@ require_relative "../session_metrics"
27
28
  require_relative "../session_commands"
28
29
  require_relative "../steer"
29
30
  require_relative "../host_registry"
31
+ require_relative "../effective_model"
30
32
  require_relative "../llm_context_override"
31
33
  require_relative "../working_set"
32
34
  require_relative "../model_catalog"
@@ -75,6 +77,7 @@ require_relative "session_show"
75
77
  require_relative "session_create"
76
78
  require_relative "session_control"
77
79
  require_relative "verify_fresh_routes"
80
+ require_relative "reload_check_route"
78
81
  require_relative "push_routes"
79
82
  require_relative "block_renderer_routes"
80
83
  require_relative "update_check"
@@ -109,6 +112,7 @@ module Samagotchi
109
112
  include SessionCreate
110
113
  include SessionControl
111
114
  include VerifyFreshRoutes
115
+ include ReloadCheckRoute
112
116
  include PushRoutes
113
117
  include BlockRendererRoutes
114
118
  include ProxyGate
@@ -146,6 +150,9 @@ module Samagotchi
146
150
  # freshly-spawned worker's bridge before answering POST /api/sessions.
147
151
  # @param working_set [Boolean] the stage view's working set
148
152
  # (web.working_set); ?working_set=1|0 overrides it for one page load
153
+ # @param reload_check [Boolean] the page checks each finished turn
154
+ # against a reload of it and posts the counts (web.reload_check,
155
+ # opt-in; POST /api/sessions/:id/reload_check logs them)
149
156
  # @param annotate_presets [String, Array] the quick replies next to
150
157
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
151
158
  # page parses them (annotate_presets.js).
@@ -184,6 +191,7 @@ module Samagotchi
184
191
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
185
192
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
186
193
  working_set: Config::BY_KEY["web.working_set"].default, wait_poll_interval: ReplyWait::POLL_INTERVAL,
194
+ reload_check: Config::BY_KEY["web.reload_check"].default,
187
195
  grants: nil, public_url: nil, public_token: nil, push: nil, start_dir: nil, update_runner: nil,
188
196
  update_blocked: nil, update_ops: nil)
189
197
  @start_dir = start_dir
@@ -199,6 +207,8 @@ module Samagotchi
199
207
  @bridge_wait_timeout = bridge_wait_timeout
200
208
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
201
209
  @working_set = working_set
210
+ @reload_check = reload_check
211
+ @reload_checks = ReloadCheckRoute::Seen.new
202
212
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
203
213
  @editor_url = Editor.template(editor)
204
214
  @hub = hub
@@ -259,6 +269,7 @@ module Samagotchi
259
269
  ["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
260
270
  ["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
261
271
  ["GET", %r{\A/api/projects\z}, :handle_projects, :owner],
272
+ ["POST", %r{\A/api/projects\z}, :handle_project_create, :owner],
262
273
  ["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
263
274
  ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
264
275
  ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
@@ -292,6 +303,7 @@ module Samagotchi
292
303
  ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
293
304
  ["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
294
305
  ["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
306
+ ["POST", %r{\A/api/sessions/([^/]+)/reload_check\z}, :handle_reload_check, :owner, :session],
295
307
  ["POST", %r{\A/api/sessions/([^/]+)/guardrails/wrong\z}, :handle_system_one_wrong, :owner, :session],
296
308
  ["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
297
309
  ["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
@@ -996,6 +1008,39 @@ module Samagotchi
996
1008
  json_response(200, ProjectList.build(@hub.snapshot, start_dir: start_dir, current_root: current_root))
997
1009
  end
998
1010
 
1011
+ def handle_project_create(req)
1012
+ body = parse_json(request_body(req))
1013
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
1014
+
1015
+ requested = body["dir"]
1016
+ requested = requested.strip if requested.is_a?(String)
1017
+ unless requested.is_a?(String) && !requested.empty? && !requested.include?("\0")
1018
+ return error_response(400, "invalid_dir", "dir must be an absolute folder")
1019
+ end
1020
+
1021
+ home = Dir.home
1022
+ dir = if requested == "~"
1023
+ home
1024
+ elsif requested.start_with?("~/")
1025
+ File.join(home, requested.delete_prefix("~/"))
1026
+ else
1027
+ requested
1028
+ end
1029
+ return error_response(400, "invalid_dir", "dir must be an absolute folder") unless dir.start_with?("/")
1030
+
1031
+ # One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
1032
+ dir = File.expand_path(dir)
1033
+ outcome = ProjectInit.call(dir, home: home)
1034
+ return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
1035
+
1036
+ if outcome.kind == :git
1037
+ failed = ProjectInit.ensure_folder(dir)
1038
+ return error_response(422, failed.reason.to_s, failed.message) if failed
1039
+ end
1040
+ dir = File.realpath(dir)
1041
+ json_response(200, outcome: outcome.kind.to_s, root: outcome.root, dir: dir)
1042
+ end
1043
+
999
1044
  # A scope folder from the page: [dir, nil] (dir nil when none was
1000
1045
  # given), or [nil, a 400 response] when it isn't an existing absolute
1001
1046
  # folder (a bookmarked worktree deleted since, a typo).
@@ -1157,30 +1202,29 @@ module Samagotchi
1157
1202
 
1158
1203
  settings = ModelSettings.all
1159
1204
  models.each do |model|
1160
- target = registry.resolve(model[:name])
1161
- names = registry.lookup_names(model[:name], target: target)
1162
- summary = SamplingSettings.summary(target, names: names, models: settings)
1205
+ effective = EffectiveModel.of(registry, ModelChoice.new(name: model[:name], typed: model[:name]), models: settings)
1206
+ summary = SamplingSettings.summary(effective.lookup)
1163
1207
  model[:sampling] = summary if summary
1164
- add_llm_context(model, target, names, settings)
1165
- add_thinking(model, target, names, settings)
1208
+ add_llm_context(model, effective)
1209
+ add_thinking(model, effective)
1166
1210
  end
1167
1211
  end
1168
1212
  end
1169
1213
 
1170
1214
  # The LLM context +model+ starts under (the start page's ⚙ panel);
1171
- # one that can't be read leaves this model without, not the others.
1172
- def add_llm_context(model, target, names, settings)
1173
- Degrade.call(:web, "model_llm_context_unreadable") do
1174
- model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
1175
- end
1215
+ # one that can't be read leaves this model without (the key absent),
1216
+ # not the others.
1217
+ def add_llm_context(model, effective)
1218
+ explained = effective.llm_context_explained({})
1219
+ model[:llm_context] = explained.summary(nil) if explained
1176
1220
  end
1177
1221
 
1178
1222
  # The thinking level +model+ starts at, with where it came from (the
1179
- # start page's ⚙ panel; SettingExplained#summary, no session).
1180
- def add_thinking(model, target, names, settings)
1181
- Degrade.call(:web, "model_thinking_unreadable") do
1182
- model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
1183
- end
1223
+ # start page's ⚙ panel; SettingExplained#summary, no session); one
1224
+ # that can't be read leaves this model without (the key absent).
1225
+ def add_thinking(model, effective)
1226
+ explained = effective.thinking_explained(own: nil)
1227
+ model[:thinking] = explained.summary if explained
1184
1228
  end
1185
1229
 
1186
1230
  # The served model a session's last generation reported (analytics.json,
@@ -1210,17 +1254,16 @@ module Samagotchi
1210
1254
  end
1211
1255
  end
1212
1256
 
1213
- # Built again when config.yml's hosts: changed (compared as read: the
1214
- # YAML read is mtime-cached), so the model picker lists a host added
1257
+ # The shared registry (HostRegistry.current: built again when
1258
+ # config.yml's hosts: changed), so the model picker lists a host added
1215
1259
  # while chi web runs. An injected registry (specs) stays.
1216
1260
  def host_registry
1217
1261
  @models_mutex.synchronize do
1218
1262
  next @registry if @registry_given
1219
1263
 
1220
- hosts = HostConfig.all
1221
- if @registry.nil? || hosts != @registry_hosts
1222
- @registry = HostRegistry.new(hosts_config: hosts)
1223
- @registry_hosts = hosts
1264
+ current = HostRegistry.current
1265
+ unless current.equal?(@registry)
1266
+ @registry = current
1224
1267
  @models_thread = nil # a listing of the old hosts
1225
1268
  end
1226
1269
  @registry
@@ -1905,6 +1948,8 @@ module Samagotchi
1905
1948
  attrs["home"] = Dir.home
1906
1949
  # Only when on: the page reads its presence.
1907
1950
  attrs["working-set"] = "1" if working_set?(req.params["working_set"])
1951
+ # The opt-in reload check (reload_check.js): only when on.
1952
+ attrs["reload-check"] = "1" if @reload_check
1908
1953
  attrs.merge!(annotate_presets_data)
1909
1954
  dir, = scope_dir(req.params["dir"])
1910
1955
  unless dir
@@ -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)
@@ -395,7 +395,7 @@ module Samagotchi
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
@@ -6,8 +6,8 @@ require_relative "push/attention"
6
6
 
7
7
  module Samagotchi
8
8
  module Web
9
- # Projects represented by the session hub, plus the start project even
10
- # when it has no sessions.
9
+ # Projects represented by the session hub, plus the start and current
10
+ # projects even when they have no sessions.
11
11
  module ProjectList
12
12
  module_function
13
13
 
@@ -24,6 +24,7 @@ module Samagotchi
24
24
  (groups[root] ||= []) << summary
25
25
  end
26
26
  groups[default_root] ||= [] if valid_root?(default_root)
27
+ groups[current_root] ||= [] if valid_root?(current_root)
27
28
 
28
29
  rows = groups.map do |root, sessions|
29
30
  default = root == default_root
@@ -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);