samagotchi 0.154.1 → 0.156.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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +1 -1
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +21 -0
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +2 -2
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/anthropic.md +5 -5
  10. data/docs/internals/attached-context.md +9 -6
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/block-renderers.md +8 -1
  13. data/docs/internals/engine.md +642 -831
  14. data/docs/internals/inline-widgets.md +136 -0
  15. data/docs/internals/llm-context-forget.md +8 -3
  16. data/docs/internals/objectives.md +8 -6
  17. data/docs/internals/participants.md +5 -5
  18. data/docs/internals/prompt-caching.md +13 -6
  19. data/docs/internals/remote-sessions.md +3 -1
  20. data/docs/internals/web-frontend.md +1 -1
  21. data/docs/internals/working-set.md +2 -1
  22. data/docs/plugins.md +2 -0
  23. data/docs/releasing.md +1 -1
  24. data/docs/sessions.md +4 -4
  25. data/docs/system-one.md +3 -2
  26. data/docs/testing.md +1 -1
  27. data/lib/samagotchi/block_renderers.rb +5 -0
  28. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  29. data/lib/samagotchi/broadcast_command.rb +3 -3
  30. data/lib/samagotchi/bundle_command/check.rb +1 -1
  31. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  33. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  34. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/child_move.rb +7 -51
  38. data/lib/samagotchi/children_status.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +1 -1
  40. data/lib/samagotchi/config.rb +17 -112
  41. data/lib/samagotchi/context_command.rb +1 -2
  42. data/lib/samagotchi/context_window.rb +3 -3
  43. data/lib/samagotchi/desktop/macos.rb +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +21 -119
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/models.rb +5 -2
  48. data/lib/samagotchi/engine/objectives.rb +30 -80
  49. data/lib/samagotchi/engine/participants.rb +15 -10
  50. data/lib/samagotchi/engine/plugins.rb +3 -1
  51. data/lib/samagotchi/engine/questions.rb +1 -1
  52. data/lib/samagotchi/engine/side_requests.rb +26 -13
  53. data/lib/samagotchi/engine/toolkit.rb +35 -191
  54. data/lib/samagotchi/engine/turn_end.rb +1 -1
  55. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  56. data/lib/samagotchi/engine/verify.rb +7 -143
  57. data/lib/samagotchi/engine.rb +166 -98
  58. data/lib/samagotchi/engine_peers.rb +1 -1
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +28 -11
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/host_config.rb +72 -1
  67. data/lib/samagotchi/host_model.rb +2 -6
  68. data/lib/samagotchi/host_registry.rb +1 -1
  69. data/lib/samagotchi/inline_widgets.rb +325 -0
  70. data/lib/samagotchi/kernel_loop.rb +215 -313
  71. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  72. data/lib/samagotchi/llm/backend.rb +3 -1
  73. data/lib/samagotchi/llm/chat_loop.rb +87 -49
  74. data/lib/samagotchi/llm/native_backend.rb +3 -2
  75. data/lib/samagotchi/llm_context_apply.rb +54 -1
  76. data/lib/samagotchi/llm_context_forget.rb +5 -3
  77. data/lib/samagotchi/llm_context_notice.rb +1 -1
  78. data/lib/samagotchi/llm_context_strategy.rb +7 -9
  79. data/lib/samagotchi/llm_context_view.rb +9 -0
  80. data/lib/samagotchi/model_binding.rb +3 -3
  81. data/lib/samagotchi/model_price.rb +4 -10
  82. data/lib/samagotchi/model_profile.rb +6 -6
  83. data/lib/samagotchi/model_settings.rb +37 -2
  84. data/lib/samagotchi/note_command.rb +1 -2
  85. data/lib/samagotchi/objectives_audit.rb +4 -3
  86. data/lib/samagotchi/participants.rb +2 -2
  87. data/lib/samagotchi/plugin/context.rb +1 -1
  88. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  89. data/lib/samagotchi/plugin/sessions.rb +2 -5
  90. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  91. data/lib/samagotchi/project_env.rb +1 -1
  92. data/lib/samagotchi/question_desk.rb +1 -1
  93. data/lib/samagotchi/reasoning_history.rb +3 -3
  94. data/lib/samagotchi/reminder_carry.rb +1 -1
  95. data/lib/samagotchi/reprefill_meter.rb +45 -0
  96. data/lib/samagotchi/review_command.rb +1 -1
  97. data/lib/samagotchi/revise_anchor.rb +1 -1
  98. data/lib/samagotchi/sampling_settings.rb +3 -3
  99. data/lib/samagotchi/self_report.rb +7 -7
  100. data/lib/samagotchi/session.rb +50 -7
  101. data/lib/samagotchi/session_access/local.rb +7 -5
  102. data/lib/samagotchi/session_access/remote.rb +17 -3
  103. data/lib/samagotchi/session_archive_command.rb +2 -2
  104. data/lib/samagotchi/session_chain.rb +7 -6
  105. data/lib/samagotchi/session_continue.rb +6 -5
  106. data/lib/samagotchi/session_delete_command.rb +1 -1
  107. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  108. data/lib/samagotchi/session_manager/emptiness.rb +138 -0
  109. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  110. data/lib/samagotchi/session_manager/listing.rb +189 -0
  111. data/lib/samagotchi/session_manager/removal.rb +190 -0
  112. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  113. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  114. data/lib/samagotchi/session_manager.rb +26 -1540
  115. data/lib/samagotchi/session_metrics.rb +1 -1
  116. data/lib/samagotchi/sessions_command.rb +1 -1
  117. data/lib/samagotchi/share_command.rb +1 -1
  118. data/lib/samagotchi/starting_marker.rb +65 -0
  119. data/lib/samagotchi/steer_cut_after.rb +3 -3
  120. data/lib/samagotchi/system_one/recorder.rb +49 -0
  121. data/lib/samagotchi/system_prompt.rb +37 -30
  122. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  123. data/lib/samagotchi/terminal_ui.rb +2 -6
  124. data/lib/samagotchi/testing/boundary.rb +7 -0
  125. data/lib/samagotchi/testing/bundle.rb +7 -5
  126. data/lib/samagotchi/testing/install.rb +3 -2
  127. data/lib/samagotchi/testing/sessions.rb +1 -1
  128. data/lib/samagotchi/thinking.rb +3 -3
  129. data/lib/samagotchi/token_usage.rb +1 -1
  130. data/lib/samagotchi/tool_activity.rb +15 -7
  131. data/lib/samagotchi/tool_declarations.rb +6 -6
  132. data/lib/samagotchi/tool_output_cut.rb +17 -0
  133. data/lib/samagotchi/tool_response.rb +4 -3
  134. data/lib/samagotchi/tool_runner.rb +96 -102
  135. data/lib/samagotchi/toolkit_binding.rb +40 -14
  136. data/lib/samagotchi/toolkit_command.rb +1 -1
  137. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  138. data/lib/samagotchi/tools/delegate.rb +10 -5
  139. data/lib/samagotchi/tools/delegate_result.rb +4 -4
  140. data/lib/samagotchi/tools/index_hint.rb +14 -0
  141. data/lib/samagotchi/tools/registry.rb +64 -44
  142. data/lib/samagotchi/tools/send_note.rb +4 -6
  143. data/lib/samagotchi/tools/tool_index.rb +150 -102
  144. data/lib/samagotchi/tools/tool_surface.rb +187 -0
  145. data/lib/samagotchi/turn_context.rb +68 -0
  146. data/lib/samagotchi/turn_stages/context_judge.rb +172 -0
  147. data/lib/samagotchi/turn_stages/objectives.rb +150 -0
  148. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +257 -0
  149. data/lib/samagotchi/turn_stages/verify_anchors.rb +168 -0
  150. data/lib/samagotchi/turn_stages/warmup.rb +82 -0
  151. data/lib/samagotchi/turn_state.rb +46 -4
  152. data/lib/samagotchi/version.rb +1 -1
  153. data/lib/samagotchi/vision_support.rb +3 -3
  154. data/lib/samagotchi/web/app.rb +15 -8
  155. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  156. data/lib/samagotchi/web/message_display.rb +1 -1
  157. data/lib/samagotchi/web/participant_access.rb +2 -2
  158. data/lib/samagotchi/web/public/app.js +5 -1
  159. data/lib/samagotchi/web/public/block_host.js +74 -199
  160. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  161. data/lib/samagotchi/web/public/block_source.js +7 -4
  162. data/lib/samagotchi/web/public/block_watch.js +256 -0
  163. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  164. data/lib/samagotchi/web/public/index.html +28 -9
  165. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  166. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  167. data/lib/samagotchi/web/public/participant.js +1 -1
  168. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  169. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  170. data/lib/samagotchi/web/public/text_roots.js +1 -1
  171. data/lib/samagotchi/web/public/verify_target.js +1 -1
  172. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  173. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  174. data/lib/samagotchi/web/public/widgets.css +70 -0
  175. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  176. data/lib/samagotchi/web/session_control.rb +3 -3
  177. data/lib/samagotchi/web/session_create.rb +3 -3
  178. data/lib/samagotchi/web/session_hub.rb +1 -1
  179. data/lib/samagotchi/web/session_show.rb +3 -3
  180. data/lib/samagotchi/web/session_summary.rb +3 -4
  181. data/lib/samagotchi/worker.rb +55 -27
  182. data/lib/samagotchi/worker_boot.rb +184 -0
  183. data/lib/samagotchi/worker_signals.rb +1 -1
  184. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  185. metadata +29 -1
@@ -353,10 +353,10 @@ module Samagotchi
353
353
  # "..", a %2F) is an unknown session before any handler makes a path.
354
354
  # (/api/sessions/resolve and /summaries take no id.)
355
355
  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]}")
356
+ return session_not_found(m.captures.first)
357
357
  end
358
358
 
359
- return session_route(principal) { send(handler, req, *m.captures) } if kind == :session
359
+ return session_route(principal, m.captures.first) { send(handler, req, *m.captures) } if kind == :session
360
360
 
361
361
  return send(handler, req, *m.captures)
362
362
  end
@@ -383,11 +383,14 @@ module Samagotchi
383
383
  # raises (Session.load, the manager's find) is a 404 not_found with
384
384
  # its message. Only these routes: elsewhere an ArgumentError is a bug,
385
385
  # #route's 500. A handler's own rescues run first.
386
- def session_route(principal = nil)
386
+ def session_route(principal = nil, id = nil)
387
387
  yield
388
388
  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")
389
+ return error_response(404, "not_found", e.message) if principal.nil? || principal.owner?
390
+
391
+ # A participant's 404 never carries a lookup's words (a path): it
392
+ # names their own session's id (valid_id? passed), nothing else.
393
+ session_not_found(id)
391
394
  end
392
395
 
393
396
  # API requests at debug level (not the page and its assets): method,
@@ -1152,7 +1155,7 @@ module Samagotchi
1152
1155
  Degrade.call(:web, "model_sampling_unreadable") do
1153
1156
  return if registry.nil? || models.empty?
1154
1157
 
1155
- settings = ConfigFile.model_settings
1158
+ settings = ModelSettings.all
1156
1159
  models.each do |model|
1157
1160
  target = registry.resolve(model[:name])
1158
1161
  names = registry.lookup_names(model[:name], target: target)
@@ -1214,7 +1217,7 @@ module Samagotchi
1214
1217
  @models_mutex.synchronize do
1215
1218
  next @registry if @registry_given
1216
1219
 
1217
- hosts = ConfigFile.hosts_config
1220
+ hosts = HostConfig.all
1218
1221
  if @registry.nil? || hosts != @registry_hosts
1219
1222
  @registry = HostRegistry.new(hosts_config: hosts)
1220
1223
  @registry_hosts = hosts
@@ -1560,7 +1563,7 @@ module Samagotchi
1560
1563
  begin
1561
1564
  session = @session_class.load(id, state_dir: default_state_dir)
1562
1565
  rescue ArgumentError
1563
- return error_response(404, "not_found", "Session not found: #{id}")
1566
+ return session_not_found(id)
1564
1567
  end
1565
1568
  # Its terminal's alone: a tab's stream would keep its worker up.
1566
1569
  return scratch_refused(id) if session.scratch
@@ -1976,6 +1979,10 @@ module Samagotchi
1976
1979
  error_response(404, "not_found", "not found: #{path}")
1977
1980
  end
1978
1981
 
1982
+ # 404 not_found for an id (or prefix) that names no session, in
1983
+ # Session::NotFound's words (SessionAccess::Remote maps it back).
1984
+ def session_not_found(given) = error_response(404, "not_found", Session::NotFound.new(given).message)
1985
+
1979
1986
  # One tab's GET /api/events: a bounded queue behind a lambda sink,
1980
1987
  # subscribed and snapshotted as one step under the hub's lock, then
1981
1988
  # drained onto the socket. The loop ends when the hub stops or the
@@ -3,6 +3,7 @@
3
3
  require "nokogiri"
4
4
  require "uri"
5
5
  require_relative "../block_renderers"
6
+ require_relative "../inline_widgets"
6
7
 
7
8
  module Samagotchi
8
9
  module Web
@@ -25,6 +26,12 @@ module Samagotchi
25
26
  extension: { header_ids: nil }
26
27
  }.freeze
27
28
 
29
+ # A code block as the parse tree has it: its language (nil for an indented
30
+ # block or a bare fence) and its literal, the source between the fences.
31
+ Fence = Data.define(:language, :literal)
32
+ # A JSON string's < > & as \u escapes: the same JSON once parsed.
33
+ JSON_MARKUP = { "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026" }.freeze
34
+
28
35
  def initialize(enabled: false)
29
36
  @enabled = enabled
30
37
  end
@@ -61,7 +68,7 @@ module Samagotchi
61
68
  })
62
69
  return nil unless html
63
70
 
64
- sanitize(html, fence_languages(markdown.to_s))
71
+ sanitize(html, fences(markdown.to_s))
65
72
  end
66
73
 
67
74
  private
@@ -81,17 +88,17 @@ module Samagotchi
81
88
  @commonmarker_installed = Gem::Specification.find_all_by_name("commonmarker").any?
82
89
  end
83
90
 
84
- # Every code block's language, in document order (nil for an indented block
85
- # or a bare fence): the highlighter drops it from the HTML, the parse tree
86
- # has it. [] for a text with no fence: its blocks have no language.
87
- def fence_languages(markdown)
91
+ # Every code block, in document order: the highlighter drops the language
92
+ # from the HTML, the parse tree has it. [] for a text with no fence: its
93
+ # blocks have no language.
94
+ def fences(markdown)
88
95
  return [] unless markdown.include?("```") || markdown.include?("~~~")
89
96
 
90
- languages = []
97
+ fences = []
91
98
  Commonmarker.parse(markdown, options: OPTIONS).walk do |node|
92
- languages << language_of(node.fence_info) if node.type == :code_block
99
+ fences << Fence.new(language: language_of(node.fence_info), literal: node.string_content) if node.type == :code_block
93
100
  end
94
- languages
101
+ fences
95
102
  end
96
103
 
97
104
  def language_of(info)
@@ -100,16 +107,34 @@ module Samagotchi
100
107
  end
101
108
 
102
109
  # After the walk, which drops every attribute it doesn't know. Blocks and
103
- # languages pair by position; a count that differs says the two parses
110
+ # fences pair by position; a count that differs says the two parses
104
111
  # disagree, and then no block gets one (it shows as source).
105
- def mark_languages(fragment, languages)
112
+ def mark_languages(fragment, fences)
106
113
  blocks = fragment.css("pre")
107
- return unless blocks.size == languages.size
114
+ return unless blocks.size == fences.size
115
+
116
+ blocks.zip(fences) do |pre, fence|
117
+ next unless fence.language
118
+
119
+ pre["data-lang"] = fence.language
120
+ mark_widget(pre, fence.literal) if fence.language == InlineWidgets::LANG
121
+ end
122
+ end
108
123
 
109
- blocks.zip(languages) { |pre, language| pre["data-lang"] = language if language }
124
+ # A chi-ui block carries what the page draws or why it doesn't. The JSON
125
+ # is an attribute value (Nokogiri escapes its quotes), and its < > & are
126
+ # \u escapes, so the HTML holds no model markup even as attribute text.
127
+ def mark_widget(pre, literal)
128
+ widget = InlineWidgets.parse(literal)
129
+ if widget.ok?
130
+ pre["data-widget"] = widget.widget_json.gsub(/[<>&]/, JSON_MARKUP)
131
+ else
132
+ pre["data-widget-error"] = widget.error
133
+ end
134
+ pre["data-widget-lenient"] = "" if widget.lenient?
110
135
  end
111
136
 
112
- def sanitize(html, languages)
137
+ def sanitize(html, fences)
113
138
  fragment = Nokogiri::HTML5.fragment(html)
114
139
  fragment.traverse do |node|
115
140
  next unless node.element?
@@ -122,7 +147,7 @@ module Samagotchi
122
147
  sanitize_attributes(node)
123
148
  end
124
149
  end
125
- mark_languages(fragment, languages)
150
+ mark_languages(fragment, fences)
126
151
  fragment.to_html
127
152
  end
128
153
 
@@ -172,7 +172,7 @@ module Samagotchi
172
172
  # id, which a card's stamp names (Bridge::CardPlaces#opening_of).
173
173
  opening_id = m[:opening_id] || m["opening_id"]
174
174
  message[:opening_id] = opening_id.to_s if norm_role == "user" && opening_id && !turn_id && !message[:merged]
175
- # The turn it revised (a Revise stack, Engine#stamp_opening), and
175
+ # The turn it revised (a Revise stack, TurnStages::VerifyAnchors#stamp_opening), and
176
176
  # whether it was a Verify turn (the page offers no Revise on its
177
177
  # answer).
178
178
  if norm_role == "user" && turn_id && !message[:merged]
@@ -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,7 +388,7 @@ 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
@@ -71,6 +71,7 @@ import { createAnnotateBar } from "./annotate_bar.js";
71
71
  import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
72
72
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
73
73
  import { createBlockHost } from "./block_host.js";
74
+ import { createWidgetDrawer } from "./inline_widgets.js";
74
75
  import { copyText, installCopy } from "./copy.js";
75
76
  import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
76
77
  import { installDetailsMotion } from "./details_motion.js";
@@ -1856,7 +1857,10 @@ historyEl.addEventListener("click", (e) => {
1856
1857
  });
1857
1858
  turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
1858
1859
  turnRoots().forEach((root) => installCopy(root));
1859
- createBlockHost({ roots: turnRoots(), sessionId: () => store.selected });
1860
+ // chi-ui blocks (inline_widgets.js) are a core drawer, ahead of the bundles'
1861
+ // renderers; a picks' Fill reply only fills the composer.
1862
+ const widgets = createWidgetDrawer({ roots: turnRoots(), composerText: () => promptEl.value, fillComposer });
1863
+ createBlockHost({ roots: turnRoots(), sessionId: () => store.selected, drawers: [widgets] });
1860
1864
  // A steps block, a thinking block and the recap open and close by hand
1861
1865
  // with their height animated (details_motion.js), in the history and on
1862
1866
  // the stage.
@@ -1,8 +1,6 @@
1
- // Draws a bundle's fenced blocks in the page (docs/internals/block-renderers.md).
2
- // One observer per turn root, filtered to TEXTS, as copy.js and answer_fold.js:
3
- // every path that fills a root (a reload, a session switch, a turn's end, an
4
- // answer_display, Revise's clones, a step's swap) arrives the same way, and
5
- // cards, the asking slot and question replies are never touched.
1
+ // Draws a bundle's fenced blocks in the page (docs/internals/block-renderers.md),
2
+ // in a hidden frame per language. Finding and watching the blocks is
3
+ // block_watch.js's; this is its frame drawer, after any core drawers.
6
4
  //
7
5
  // The DOM is the state: a <figure class="block-drawn"> right before the <pre>
8
6
  // (block_source.js) says it is drawn, so a Revise clone needs nothing, and
@@ -16,16 +14,13 @@
16
14
  import { html, render as litRender } from "./vendor/lit-html.js";
17
15
  import { createBlockFrame, FRAME_TIMEOUT_MS } from "./block_frame.js";
18
16
  import { DRAWN, figureOf, setSourceShown } from "./block_source.js";
17
+ import { createBlockWatch, failBlock, holdingView } from "./block_watch.js";
19
18
  import { copyText } from "./copy.js";
20
- import { keepFollowing, shouldFollowScroll } from "./scroll.js";
21
- import { TEXTS } from "./text_roots.js";
22
19
 
23
- const BLOCKS = "pre[data-lang]";
24
20
  // MessageDisplay::MAX_RENDER_BYTES's number: a source over it stays source.
25
21
  export const MAX_SOURCE_BYTES = 64 * 1024;
26
22
  export const CACHE_SIZE = 200;
27
23
  export const MARK_PREFIX = "chi_block_rendering:";
28
- const IO_MARGIN = "600px 0px";
29
24
  const MAX_RETRIES = 5;
30
25
  // The biggest picture a page shows, whatever the renderer: a model-written
31
26
  // directive can ask Mermaid for an SVG tens of millions of px tall, which breaks
@@ -126,43 +121,6 @@ export function currentTheme(doc = document, win = window) {
126
121
  };
127
122
  }
128
123
 
129
- // The nearest scroller holding +el+, +root+ at the latest. IntersectionObserver
130
- // needs it named: with root:null a rootMargin doesn't pass an overflow:auto
131
- // between, so a block just under #history's fold wouldn't count.
132
- function scrollerOf(el, root, win) {
133
- for (let at = el.parentElement; at && at !== root; at = at.parentElement) {
134
- const { overflowY } = win.getComputedStyle(at);
135
- if (overflowY === "auto" || overflowY === "scroll") return at;
136
- }
137
- return root;
138
- }
139
-
140
- // One IntersectionObserver per scroller. +el+ moved to another scroller (the
141
- // stage's hand-off to the history) moves with it.
142
- function ioWatcher(win) {
143
- const observers = new Map();
144
- const watched = new WeakMap();
145
- return (el, scroller, onNear) => {
146
- let io = observers.get(scroller);
147
- if (!io) {
148
- io = new win.IntersectionObserver((entries) => {
149
- for (const entry of entries) {
150
- if (!entry.isIntersecting) continue;
151
- io.unobserve(entry.target);
152
- const near = watched.get(entry.target);
153
- watched.delete(entry.target);
154
- near?.onNear();
155
- }
156
- }, { root: scroller, rootMargin: IO_MARGIN });
157
- observers.set(scroller, io);
158
- }
159
- const before = watched.get(el);
160
- if (before && before.io !== io) before.io.unobserve(el);
161
- watched.set(el, { io, onNear });
162
- io.observe(el);
163
- };
164
- }
165
-
166
124
  function guardedStorage(get) {
167
125
  const use = (fn, fallback) => { try { return fn(get()); } catch (_) { return fallback; } };
168
126
  return {
@@ -172,26 +130,22 @@ function guardedStorage(get) {
172
130
  };
173
131
  }
174
132
 
175
- // @param roots the turn roots (app.js turnRoots())
176
- // @param sessionId () => the open session's id: the frames go on a change, to
177
- // limit what they hold (docs/internals/block-renderers.md, residual risk)
178
- // The rest are seams for the specs.
179
- export function createBlockHost({
180
- roots, sessionId, listRenderers = fetchRendererList, fetchFile = fetchRendererFile, makeFrame = createBlockFrame,
133
+
134
+ // The drawer of a bundle's renderers (block_watch.js has the contract): one
135
+ // frame per language, waiting for /api/block_renderers to say who draws what.
136
+ // @param roots the turn roots, for the view held and the zoom's listeners
137
+ // The rest are createBlockHost's.
138
+ export function createFrameDrawer({
139
+ roots, listRenderers = fetchRendererList, fetchFile = fetchRendererFile, makeFrame = createBlockFrame,
181
140
  decodeImage = async (url) => { const img = new win.Image(); img.src = url; await img.decode(); return { width: img.naturalWidth, height: img.naturalHeight }; },
182
- decodeTimeoutMs = DECODE_TIMEOUT_MS, theme = currentTheme, copy = copyText,
183
- watch = null, doc = document, win = window, storage = guardedStorage(() => globalThis.sessionStorage),
141
+ decodeTimeoutMs = DECODE_TIMEOUT_MS, theme = currentTheme,
142
+ doc = document, win = window, storage = guardedStorage(() => globalThis.sessionStorage),
184
143
  }) {
185
- const near = watch || ioWatcher(win);
186
144
  const cache = new Map();
187
145
  const frames = new Map();
188
146
  // A language whose renderer didn't load, and why: until the session changes,
189
147
  // its blocks don't fetch it again one by one.
190
148
  const broken = new Map();
191
- // Blocks with a draw on its way (not a drawn state: that is the figure): pre =>
192
- // {target, scroller, start, drawing}. Until it starts, a block whose scroller
193
- // changed (the stage's hand-off to the history) gets watched there.
194
- const waiting = new WeakMap();
195
149
  // The marks of the renders running now: mark => when it started. Such a mark isn't
196
150
  // a leftover from an earlier load.
197
151
  const inFlight = new Map();
@@ -199,17 +153,19 @@ export function createBlockHost({
199
153
  // render has its blocks wait for that one, and each draws into whichever is connected.
200
154
  const coming = new Map();
201
155
  const retries = new WeakMap();
156
+ // What needsDraw checked, so draw draws that: pre => {lang, source, renderer}.
157
+ const checked = new WeakMap();
202
158
  let list = null;
203
159
  let listLoad = null;
204
- let session = sessionId();
205
160
 
206
- function withRenderers(fn) {
207
- if (list) { fn(list); return; }
161
+ // The list once loaded; till then a promise of it, asked for once per page load.
162
+ function renderers() {
163
+ if (list) return list;
208
164
  listLoad ??= listRenderers().then(
209
- (renderers) => { list = new Map((renderers || []).map((r) => [r.lang, r])); return list; },
165
+ (got) => { list = new Map((got || []).map((r) => [r.lang, r])); return list; },
210
166
  () => { list = new Map(); return list; }, // a failed list: no drawing
211
167
  );
212
- listLoad.then(fn);
168
+ return listLoad;
213
169
  }
214
170
 
215
171
  function dropFrames() {
@@ -218,13 +174,6 @@ export function createBlockHost({
218
174
  broken.clear();
219
175
  }
220
176
 
221
- function noticeSession() {
222
- const now = sessionId();
223
- if (now === session) return;
224
- session = now;
225
- dropFrames();
226
- }
227
-
228
177
  function frameFor(lang, renderer) {
229
178
  let frame = frames.get(lang);
230
179
  if (frame && !frame.dead()) return frame;
@@ -242,24 +191,7 @@ export function createBlockHost({
242
191
 
243
192
  const cacheKey = (lang, themeName, source) => `${lang}\u0000${themeName}\u0000${source}`;
244
193
  const markKey = (lang, source) => `${MARK_PREFIX}${lang}:${sourceHash(source)}`;
245
-
246
- function rootOf(el) {
247
- return roots.find((root) => root.contains(el)) || roots[0];
248
- }
249
-
250
- // +mutate+ with the view held: a viewer at the bottom stays there, and a
251
- // block above a reader's view keeps what they read where it is. Browsers that
252
- // anchor scrolling have done it by the second read, and there is nothing left.
253
- function holdingView(pre, mutate, bottomOf) {
254
- const scroller = scrollerOf(pre, rootOf(pre), win);
255
- if (shouldFollowScroll(scroller)) return keepFollowing(scroller, mutate);
256
- const before = pre.getBoundingClientRect().bottom;
257
- if (before > scroller.getBoundingClientRect().top) return mutate();
258
- const result = mutate();
259
- const after = bottomOf();
260
- if (after !== before) scroller.scrollTop += after - before;
261
- return result;
262
- }
194
+ const langOf = (pre) => pre.getAttribute("data-lang");
263
195
 
264
196
  function setMark(mark) {
265
197
  storage.set(mark);
@@ -347,28 +279,10 @@ export function createBlockHost({
347
279
  return;
348
280
  }
349
281
  const figure = makeFigure(picture, themeName, lang);
350
- holdingView(pre, () => {
282
+ holdingView(pre, roots, () => {
351
283
  pre.before(figure);
352
284
  setSourceShown(figure, pre, false);
353
- }, () => figure.getBoundingClientRect().bottom);
354
- }
355
-
356
- // The <pre> stays as it is, under one muted line: never markup (the words are
357
- // a renderer's, driven by the model's input), and shown by CSS, so a bubble's
358
- // textContent, its dedupe and an annotation's text stay what they were (copy.js's
359
- // rule for its buttons).
360
- function fail(pre, lang, message) {
361
- if (!pre.isConnected) return;
362
- pre.dataset.blockFailed = "1";
363
- const note = doc.createElement("div");
364
- note.className = "block-note";
365
- const first = String(message ?? "").split("\n")[0].trim();
366
- const words = `Couldn't draw this ${lang} block${first ? `: ${first}` : ""}`;
367
- note.dataset.text = words;
368
- note.setAttribute("role", "note");
369
- note.setAttribute("aria-label", words);
370
- note.title = "Click to copy";
371
- pre.after(note);
285
+ }, () => figure.getBoundingClientRect().bottom, win);
372
286
  }
373
287
 
374
288
  function pictureFor(key, lang, source, renderer, now) {
@@ -407,21 +321,37 @@ export function createBlockHost({
407
321
  }
408
322
  }
409
323
 
410
- async function draw(pre, lang, source, renderer) {
324
+ // A claimed language has a renderer: the list is loaded by then.
325
+ function needsDraw(pre, figure) {
326
+ const lang = langOf(pre);
327
+ const source = pre.textContent;
328
+ const now = theme(doc, win);
329
+ if (figure && figure.dataset.theme === now.name) return false;
330
+ const hit = cache.get(cacheKey(lang, now.name, source));
331
+ if (hit) { place(pre, hit, now.name, lang); return false; } // no flash: this frame
332
+ if (!figure) {
333
+ if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { failBlock(pre, lang, "it is over 64 KB"); return false; }
334
+ const mark = markKey(lang, source);
335
+ if (storage.has(mark) && !inFlight.has(mark)) { failBlock(pre, lang, "it hung or took too long the last time"); return false; }
336
+ }
337
+ checked.set(pre, { lang, source, renderer: list.get(lang) });
338
+ return true;
339
+ }
340
+
341
+ async function draw(pre, { reconsider }) {
342
+ const { lang, source, renderer } = checked.get(pre);
411
343
  const now = theme(doc, win);
412
344
  const key = cacheKey(lang, now.name, source);
413
345
  const hit = cache.get(key);
414
- if (hit) { waiting.delete(pre); place(pre, hit, now.name, lang); return; }
415
- if (broken.has(lang)) { waiting.delete(pre); fail(pre, lang, broken.get(lang)); return; }
346
+ if (hit) { place(pre, hit, now.name, lang); return; }
347
+ if (broken.has(lang)) { failBlock(pre, lang, broken.get(lang)); return; }
416
348
  let retry = false;
417
349
  try {
418
350
  place(pre, await pictureFor(key, lang, source, renderer, now), now.name, lang);
419
351
  } catch (e) {
420
352
  // A redraw that failed keeps the picture it has.
421
- if (!e?.dropped && !figureOf(pre)) fail(pre, lang, e?.message);
353
+ if (!e?.dropped && !figureOf(pre)) failBlock(pre, lang, e?.message);
422
354
  retry = !!e?.dropped;
423
- } finally {
424
- waiting.delete(pre);
425
355
  }
426
356
  // Queued behind a frame that died (another block's timeout): a new frame's
427
357
  // turn. A session switch's frames go with their blocks, which aren't connected.
@@ -430,100 +360,45 @@ export function createBlockHost({
430
360
  if (!retry) return;
431
361
  const tries = (retries.get(pre) || 0) + 1;
432
362
  retries.set(pre, tries);
433
- if (tries <= MAX_RETRIES) consider(pre);
434
- else fail(pre, lang, "its renderer keeps stopping");
435
- }
436
-
437
- // A copy.js wrap took the <pre> away from a figure placed first: back with it.
438
- function rejoin(pre) {
439
- if (figureOf(pre) || !pre.hidden) return;
440
- const wrap = pre.parentElement;
441
- const stray = wrap?.classList.contains("code-wrap") && wrap.previousElementSibling;
442
- if (stray?.classList?.contains(DRAWN)) wrap.insertBefore(stray, pre);
443
- }
444
-
445
- function watchFor(entry) {
446
- const scroller = scrollerOf(entry.target, rootOf(entry.target), win);
447
- if (scroller === entry.scroller) return;
448
- entry.scroller = scroller;
449
- near(entry.target, scroller, entry.start);
450
- }
451
-
452
- function consider(pre) {
453
- if (!pre.isConnected || !pre.closest(TEXTS) || pre.dataset.blockFailed) return;
454
- const pending = waiting.get(pre);
455
- if (pending) {
456
- if (!pending.drawing) watchFor(pending);
457
- return;
458
- }
459
- rejoin(pre);
460
- const lang = pre.getAttribute("data-lang");
461
- const source = pre.textContent;
462
- if (!source.trim()) return; // an empty fence is left alone
463
- withRenderers((renderers) => {
464
- const renderer = renderers.get(lang);
465
- if (!renderer || !pre.isConnected || waiting.has(pre) || pre.dataset.blockFailed) return;
466
- const now = theme(doc, win);
467
- const figure = figureOf(pre);
468
- if (figure && figure.dataset.theme === now.name) return;
469
- const hit = cache.get(cacheKey(lang, now.name, source));
470
- if (hit) { place(pre, hit, now.name, lang); return; } // no flash: this frame
471
- if (!figure) {
472
- if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { fail(pre, lang, "it is over 64 KB"); return; }
473
- const mark = markKey(lang, source);
474
- if (storage.has(mark) && !inFlight.has(mark)) { fail(pre, lang, "it hung or took too long the last time"); return; }
475
- }
476
- // A drawn block's <pre> is hidden, which never intersects: its figure does.
477
- const entry = {
478
- target: figure || pre,
479
- scroller: null,
480
- drawing: false,
481
- start: () => { entry.drawing = true; draw(pre, lang, source, renderer); },
482
- };
483
- waiting.set(pre, entry);
484
- watchFor(entry);
485
- });
486
- }
487
-
488
- function scan(el) {
489
- if (el.matches?.(BLOCKS)) consider(el);
490
- for (const pre of el.querySelectorAll?.(BLOCKS) || []) consider(pre);
363
+ if (tries <= MAX_RETRIES) reconsider(pre);
364
+ else failBlock(pre, lang, "its renderer keeps stopping");
491
365
  }
492
366
 
493
367
  for (const root of roots) {
494
- new win.MutationObserver((records) => {
495
- noticeSession();
496
- for (const record of records) {
497
- for (const node of record.addedNodes) if (node.nodeType === 1) scan(node);
498
- }
499
- }).observe(root, { childList: true, subtree: true });
500
- scan(root);
501
-
502
368
  root.addEventListener("mousedown", (e) => {
503
- if (e.target.closest?.(".block-toggle, .block-zoom")) e.preventDefault();
369
+ if (e.target.closest?.(".block-zoom")) e.preventDefault();
504
370
  });
505
371
  // Delegated, so a Revise clone's work too.
506
372
  root.addEventListener("click", (e) => {
507
- const toggle = e.target.closest?.(".block-toggle");
508
- if (toggle) {
509
- e.preventDefault();
510
- e.stopPropagation();
511
- const figure = toggle.parentElement;
512
- setSourceShown(figure, figure.nextElementSibling, !figure.classList.contains("source"));
513
- return;
514
- }
515
- const note = e.target.closest?.(".block-note");
516
- if (note) { copy(note.dataset.text); return; }
517
- if (e.target.closest?.(".block-zoom, .block-img")) {
518
- const figure = e.target.closest(`.${DRAWN}`);
519
- setZoomed(figure, !figure.classList.contains("natural"));
520
- }
373
+ if (!e.target.closest?.(".block-zoom, .block-img")) return;
374
+ const figure = e.target.closest(`.${DRAWN}`);
375
+ setZoomed(figure, !figure.classList.contains("natural"));
521
376
  });
522
377
  }
523
378
 
379
+ return {
380
+ claims(lang) {
381
+ const known = renderers();
382
+ return known.then ? known.then((got) => got.has(lang)) : known.has(lang);
383
+ },
384
+ needsDraw,
385
+ draw,
386
+ sessionChanged: dropFrames,
387
+ dropFrames,
388
+ };
389
+ }
390
+
391
+ // @param roots the turn roots (app.js turnRoots())
392
+ // @param sessionId () => the open session's id: the frames go on a change, to
393
+ // limit what they hold (docs/internals/block-renderers.md, residual risk)
394
+ // @param drawers core drawers, asked before the bundle renderers
395
+ // The rest are seams for the specs.
396
+ export function createBlockHost({ roots, sessionId, drawers = [], copy = copyText, watch = null, win = window, ...frameOpts }) {
397
+ const frames = createFrameDrawer({ roots, win, ...frameOpts });
398
+ const watcher = createBlockWatch({ roots, sessionId, drawers: [...drawers, frames], watch, copy, win });
399
+ // A system theme change redraws through the figure (needsDraw's theme check).
524
400
  win.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
525
- for (const root of roots) scan(root);
401
+ for (const root of roots) watcher.scan(root);
526
402
  });
527
-
528
- return { scan, dropFrames };
403
+ return { scan: watcher.scan, dropFrames: frames.dropFrames };
529
404
  }
@@ -8,6 +8,8 @@
8
8
  .block-img{display:block;max-width:100%;max-height:50000px;object-fit:contain;height:auto;margin:0 auto;cursor:zoom-in}
9
9
  .block-drawn.natural .block-img{max-width:50000px;margin:0;cursor:zoom-out}
10
10
  .block-drawn.source .block-scroll{display:none}
11
+ /* A chi-ui widget (inline_widgets.js): its host holds the shadow root, whose own sheet is widgets.css; the source hides it as the picture. */
12
+ .block-drawn.source>.widget-host{display:none}
11
13
  .block-toggle,.block-zoom{position:absolute;top:5px;right:5px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-3);opacity:0;transition:opacity .12s}
12
14
  .code-wrap>.block-drawn>.block-toggle{right:34px}
13
15
  .block-zoom{right:34px}
@@ -1,4 +1,4 @@
1
- // A drawn block's two faces (block_host.js): the picture, or its source. The
1
+ // A drawn block's two faces (block_watch.js, block_host.js): the picture, or its source. The
2
2
  // source <pre> stays in the DOM right after the figure, `hidden` while the
3
3
  // picture shows, so whatever reads text (a claim's walk, copy) sees what it
4
4
  // always did. The DOM is the state: a Revise clone carries both with it.
@@ -18,15 +18,18 @@ export function figureOf(pre) {
18
18
  return before?.classList?.contains(DRAWN) ? before : null;
19
19
  }
20
20
 
21
- // The source shown (and the picture hidden), or the other way round.
21
+ // The source shown (and the picture hidden), or the other way round. A
22
+ // figure that isn't a picture names its own face (data-toggle-picture: a
23
+ // chi-ui widget's "Show the widget").
22
24
  export function setSourceShown(figure, pre, shown) {
23
25
  pre.hidden = !shown;
24
26
  figure.classList.toggle("source", shown);
25
27
  const toggle = figure.querySelector(":scope > .block-toggle");
26
28
  if (!toggle) return;
27
29
  toggle.setAttribute("aria-pressed", shown ? "true" : "false");
28
- toggle.setAttribute("aria-label", shown ? HIDE : SHOW);
29
- toggle.title = shown ? HIDE : SHOW;
30
+ const hide = figure.dataset.togglePicture || HIDE;
31
+ toggle.setAttribute("aria-label", shown ? hide : SHOW);
32
+ toggle.title = shown ? hide : SHOW;
30
33
  }
31
34
 
32
35
  // A jump that lands in a drawn block's source shows it (history_reveal.js).