samagotchi 0.133.0 → 0.135.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +50 -34
  6. data/docs/configuration.md +9 -6
  7. data/docs/guardrails.md +146 -13
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/internals/engine.md +6 -3
  10. data/docs/internals/participants.md +7 -6
  11. data/docs/internals/tool-guardrails.md +2 -0
  12. data/docs/internals/web-frontend.md +262 -0
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +5 -2
  15. data/docs/testing.md +34 -4
  16. data/lib/samagotchi/bridge.rb +11 -6
  17. data/lib/samagotchi/bridge_client.rb +8 -5
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_home.rb +12 -2
  20. data/lib/samagotchi/config.rb +0 -2
  21. data/lib/samagotchi/desktop/macos.rb +19 -4
  22. data/lib/samagotchi/engine.rb +82 -12
  23. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  24. data/lib/samagotchi/guardrails/approval.rb +41 -5
  25. data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
  27. data/lib/samagotchi/guardrails/pair_command.rb +828 -86
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
  30. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
  32. data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
  33. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  34. data/lib/samagotchi/guardrails/targets.rb +8 -3
  35. data/lib/samagotchi/guardrails_command.rb +13 -10
  36. data/lib/samagotchi/home_command.rb +22 -6
  37. data/lib/samagotchi/home_doctor.rb +36 -3
  38. data/lib/samagotchi/home_notice.rb +8 -1
  39. data/lib/samagotchi/hooks/loader.rb +5 -0
  40. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  41. data/lib/samagotchi/log_line.rb +12 -0
  42. data/lib/samagotchi/message_parts.rb +1 -1
  43. data/lib/samagotchi/project_env.rb +3 -11
  44. data/lib/samagotchi/revise_anchor.rb +33 -0
  45. data/lib/samagotchi/self_report.rb +3 -1
  46. data/lib/samagotchi/session_inbox.rb +20 -12
  47. data/lib/samagotchi/session_manager.rb +13 -11
  48. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  49. data/lib/samagotchi/system_one/self_protect.rb +70 -16
  50. data/lib/samagotchi/testing/waiting.rb +13 -0
  51. data/lib/samagotchi/toolkit_binding.rb +16 -0
  52. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  53. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  54. data/lib/samagotchi/tools/execute.rb +6 -6
  55. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  56. data/lib/samagotchi/tools/read.rb +5 -5
  57. data/lib/samagotchi/turn_anchors.rb +49 -0
  58. data/lib/samagotchi/update_command.rb +7 -3
  59. data/lib/samagotchi/update_hint.rb +6 -1
  60. data/lib/samagotchi/verify_anchor.rb +5 -5
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -14
  63. data/lib/samagotchi/web/message_display.rb +17 -1
  64. data/lib/samagotchi/web/participant_access.rb +2 -2
  65. data/lib/samagotchi/web/participant_view.rb +8 -0
  66. data/lib/samagotchi/web/public/activity.js +1 -1
  67. data/lib/samagotchi/web/public/annotations.js +1 -1
  68. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  69. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  70. data/lib/samagotchi/web/public/app.js +462 -338
  71. data/lib/samagotchi/web/public/bubbles.js +3 -3
  72. data/lib/samagotchi/web/public/cards_place.js +9 -4
  73. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  74. data/lib/samagotchi/web/public/claim_link.js +4 -2
  75. data/lib/samagotchi/web/public/composer.css +11 -1
  76. data/lib/samagotchi/web/public/context_chips.js +9 -6
  77. data/lib/samagotchi/web/public/data.js +4 -2
  78. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  79. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  80. data/lib/samagotchi/web/public/history_reveal.js +27 -0
  81. data/lib/samagotchi/web/public/index.html +82 -37
  82. data/lib/samagotchi/web/public/latest.js +46 -0
  83. data/lib/samagotchi/web/public/lists_view.js +15 -13
  84. data/lib/samagotchi/web/public/live_timing.js +2 -0
  85. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  86. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  87. data/lib/samagotchi/web/public/method_api.js +14 -0
  88. data/lib/samagotchi/web/public/notifications.js +4 -11
  89. data/lib/samagotchi/web/public/participant.js +11 -6
  90. data/lib/samagotchi/web/public/participant_names.js +1 -1
  91. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  92. data/lib/samagotchi/web/public/project_picker.js +18 -22
  93. data/lib/samagotchi/web/public/question_cards.js +2 -2
  94. data/lib/samagotchi/web/public/refs.js +6 -7
  95. data/lib/samagotchi/web/public/restore.js +2 -0
  96. data/lib/samagotchi/web/public/review_start.js +1 -1
  97. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  98. data/lib/samagotchi/web/public/scope.js +12 -20
  99. data/lib/samagotchi/web/public/send_turn.js +29 -16
  100. data/lib/samagotchi/web/public/session_actions.js +15 -14
  101. data/lib/samagotchi/web/public/session_fields.js +152 -0
  102. data/lib/samagotchi/web/public/session_payload.js +35 -0
  103. data/lib/samagotchi/web/public/session_state.js +2 -0
  104. data/lib/samagotchi/web/public/session_store.js +191 -0
  105. data/lib/samagotchi/web/public/stage_model.js +4 -4
  106. data/lib/samagotchi/web/public/stage_view.js +11 -16
  107. data/lib/samagotchi/web/public/start_page.js +4 -4
  108. data/lib/samagotchi/web/public/start_settings.js +29 -18
  109. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  110. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  111. data/lib/samagotchi/web/public/task_stop.js +1 -1
  112. data/lib/samagotchi/web/public/timing.js +7 -3
  113. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
  114. data/lib/samagotchi/web/public/turn_events.js +2 -2
  115. data/lib/samagotchi/web/public/turn_html.js +17 -9
  116. data/lib/samagotchi/web/public/turn_model.js +6 -6
  117. data/lib/samagotchi/web/public/turn_range.js +32 -0
  118. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  119. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  120. data/lib/samagotchi/web/public/verify_target.js +7 -10
  121. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  122. data/lib/samagotchi/web/server.rb +4 -4
  123. data/lib/samagotchi/web/session_control.rb +2 -2
  124. data/lib/samagotchi/worker.rb +9 -9
  125. data/lib/samagotchi/worker_inbound.rb +6 -5
  126. metadata +16 -3
  127. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -81,10 +81,10 @@ module Samagotchi
81
81
 
82
82
  preview_block = [
83
83
  "[TRUNCATED_PREVIEW_HEAD]",
84
- preview[:head],
85
- "[... omitted #{preview[:omitted_bytes]} bytes ...]",
84
+ preview.head,
85
+ "[... omitted #{preview.omitted_bytes} bytes ...]",
86
86
  "[TRUNCATED_PREVIEW_TAIL]",
87
- preview[:tail]
87
+ preview.tail
88
88
  ].join("\n")
89
89
 
90
90
  metadata_lines = [
@@ -92,8 +92,8 @@ module Samagotchi
92
92
  "path=#{path}",
93
93
  "preview_strategy=head_tail",
94
94
  "file_bytes=#{file_size}",
95
- "returned_preview_bytes=#{preview[:returned_preview_bytes]}",
96
- "omitted_bytes=#{preview[:omitted_bytes]}"
95
+ "returned_preview_bytes=#{preview.returned_preview_bytes}",
96
+ "omitted_bytes=#{preview.omitted_bytes}"
97
97
  ]
98
98
 
99
99
  telemetry = telemetry_lines_for(preview_block)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "revise_anchor"
4
+ require_relative "verify_anchor"
5
+
6
+ module Samagotchi
7
+ # What a turn points at in an earlier turn: a Verify turn's claim
8
+ # (VerifyAnchor) and a Revise turn's draft (ReviseAnchor). One carrier
9
+ # through every layer a turn's input travels (chi web's POST /turn, the
10
+ # Bridge, the input file, SessionInbox::Input, WorkerInbound::Prompt, a
11
+ # failed turn's restore), so a new anchor is one field here. On the wire
12
+ # and in the input file each is its own key ("verify", "revises"); a
13
+ # missing or malformed one is nil. The Engine takes them as run_turn's
14
+ # keywords (#kwargs): what each means is its own.
15
+ TurnAnchors = Data.define(:verify, :revises) do
16
+ def self.from(value)
17
+ return value if value.is_a?(self)
18
+
19
+ fields = value.is_a?(Hash) ? value.to_h { |key, entry| [key.to_s, entry] } : {}
20
+ new(verify: fields["verify"], revises: fields["revises"])
21
+ end
22
+
23
+ # Whether a parsed input file names an anchor (a Hash under its key,
24
+ # well-formed or not): SessionInbox.turn_end_data? holds it.
25
+ def self.named_in?(data) = %w[verify revises].any? { |key| data[key].is_a?(Hash) }
26
+
27
+ def initialize(verify: nil, revises: nil)
28
+ super(verify: VerifyAnchor.from(verify), revises: ReviseAnchor.from(revises))
29
+ end
30
+
31
+ def empty? = verify.nil? && revises.nil?
32
+
33
+ # The anchors given, each answering FEATURE (the worker's Bridge
34
+ # feature it needs) and UNAVAILABLE (the refusal without it).
35
+ def present = [verify, revises].compact
36
+
37
+ # The body's and the input file's keys.
38
+ def to_file = { "verify" => verify&.to_file, "revises" => revises&.to_file }.compact
39
+
40
+ # A :prompt_restored event's fields.
41
+ def to_h = { verify: verify&.to_h, revises: revises&.to_h }.compact
42
+
43
+ # Engine#run_turn's keywords.
44
+ def kwargs = { verify:, revises: }.compact
45
+
46
+ # A participant's: they can't Revise (participant_access.rb, the Bridge).
47
+ def without_revises = with(revises: nil)
48
+ end
49
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "cli/command"
4
4
  require_relative "cli/flags"
5
5
  require_relative "chi_home"
6
+ require_relative "child_env"
6
7
  require_relative "config"
7
8
  require_relative "desktop"
8
9
  require_relative "gem_update"
@@ -89,7 +90,7 @@ module Samagotchi
89
90
  # Built after the home is known: its install env goes into the home.
90
91
  @gem_update_given = gem_update
91
92
  @exec = exec || ->(argv) { Kernel.exec(*argv) }
92
- @bundler = bundler.nil? ? (defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty?) : bundler
93
+ @bundler = bundler.nil? ? ChiHome.under_bundler? : bundler
93
94
  @argv = argv.dup
94
95
  @stdout = stdout
95
96
  @stderr = stderr
@@ -168,11 +169,14 @@ module Samagotchi
168
169
 
169
170
  # A chi on another Ruby than the home's updates the home: exec its chi
170
171
  # (newer?, --to and the bundle rows are then the home's), once
171
- # (IN_HOME_ENV).
172
+ # (IN_HOME_ENV), outside a `bundle exec`'s bundle (ChildEnv.unbundled,
173
+ # as worker spawns and chi web's restart boot it). hand_over never runs
174
+ # under Bundler: the gem row skips there.
172
175
  def to_home(options)
173
176
  notice(options, "updating the chi home (#{@run_home.ruby})")
174
177
  @stdout.flush
175
- @exec.call([{ IN_HOME_ENV => "1" }, *@run_home.boot_argv(run: ChiHome::CHI_BOOT), "--", "update", *@argv])
178
+ @exec.call([ChildEnv.unbundled.merge(IN_HOME_ENV => "1"), *@run_home.boot_argv(run: ChiHome::CHI_BOOT), "--",
179
+ "update", *@argv])
176
180
  0 # only when exec is a spec's stand-in
177
181
  end
178
182
 
@@ -52,8 +52,13 @@ module Samagotchi
52
52
  "chi #{version}: #{parts.join(" and ")} can be updated: chi update"
53
53
  end
54
54
 
55
+ # The helper whose chi reads this chi's config dir: an isolated run
56
+ # (XDG_CONFIG_HOME elsewhere) never hints about the real one.
55
57
  def default_helper(env)
56
- Desktop.supported? ? Desktop::MacOS.new(env: env) : nil
58
+ return nil unless Desktop.supported?
59
+
60
+ helper = Desktop::MacOS.new(env: env, register: false)
61
+ helper.reads_this_config? ? helper : nil
57
62
  end
58
63
  end
59
64
  end
@@ -3,9 +3,6 @@
3
3
  module Samagotchi
4
4
  # A claim selected in an earlier turn, carried only by a Verify turn.
5
5
  VerifyAnchor = Data.define(:turn_id, :quote) do
6
- UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
7
- QUOTE_MAX = 200
8
-
9
6
  def self.from(value)
10
7
  return value if value.is_a?(self)
11
8
  return nil unless value.is_a?(Hash)
@@ -15,8 +12,8 @@ module Samagotchi
15
12
 
16
13
  turn_id = fields["turn_id"]
17
14
  quote = fields["quote"]
18
- return nil unless turn_id.is_a?(String) && UUID.match?(turn_id)
19
- return nil unless quote.is_a?(String) && quote.length.between?(1, QUOTE_MAX) && !quote.strip.empty?
15
+ return nil unless turn_id.is_a?(String) && self::UUID.match?(turn_id)
16
+ return nil unless quote.is_a?(String) && quote.length.between?(1, self::QUOTE_MAX) && !quote.strip.empty?
20
17
 
21
18
  new(turn_id:, quote:)
22
19
  end
@@ -26,6 +23,9 @@ module Samagotchi
26
23
  def to_file = { "turn_id" => turn_id, "quote" => quote }
27
24
  end
28
25
 
26
+ # The shape of the turn id an anchor names (ReviseAnchor's too).
27
+ VerifyAnchor::UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
28
+ VerifyAnchor::QUOTE_MAX = 200
29
29
  VerifyAnchor::FEATURE = "verify"
30
30
  VerifyAnchor::UNAVAILABLE = {
31
31
  "error" => "verify_unavailable",
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.133.0"
4
+ VERSION = "0.135.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -134,8 +134,6 @@ module Samagotchi
134
134
 
135
135
  # @param bridge_wait_timeout [Float] bounded seconds to wait for a
136
136
  # freshly-spawned worker's bridge before answering POST /api/sessions.
137
- # @param view ["stage", "turn"] the page's view of a turn (web.view);
138
- # ?view=stage|turn overrides it for one page load
139
137
  # @param working_set [Boolean] the stage view's working set
140
138
  # (web.working_set); ?working_set=1|0 overrides it for one page load
141
139
  # @param annotate_presets [String, Array] the quick replies next to
@@ -168,7 +166,7 @@ module Samagotchi
168
166
  # @param start_dir [String] the folder chi web starts in (StartDir):
169
167
  # where a new session without a dir starts (nil: the cwd)
170
168
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
171
- bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
169
+ bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, hub: nil,
172
170
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
173
171
  editor: Config::BY_KEY["web.editor"].default,
174
172
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
@@ -188,7 +186,6 @@ module Samagotchi
188
186
  @public_dir = public_dir || File.expand_path("public", __dir__)
189
187
  @bridge_wait_timeout = bridge_wait_timeout
190
188
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
191
- @view = view
192
189
  @working_set = working_set
193
190
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
194
191
  @editor_url = Editor.template(editor)
@@ -2015,8 +2012,7 @@ module Samagotchi
2015
2012
  # The owner's home: a claim link's quote reads it "~", as the
2016
2013
  # participant page shows it (claim_link.js).
2017
2014
  attrs["home"] = Dir.home
2018
- attrs["view"] = view_name(req.params["view"])
2019
- # Only when on: the page reads its presence (the turn view ignores it).
2015
+ # Only when on: the page reads its presence.
2020
2016
  attrs["working-set"] = "1" if working_set?(req.params["working_set"])
2021
2017
  attrs.merge!(annotate_presets_data)
2022
2018
  dir, = scope_dir(req.params["dir"])
@@ -2060,14 +2056,6 @@ module Samagotchi
2060
2056
  File.directory?(dir) && ProjectScope.root_for(dir) ? dir : nil
2061
2057
  end
2062
2058
 
2063
- VIEWS = Config::BY_KEY["web.view"].enum_values
2064
-
2065
- # The view for this page load: ?view=stage|turn wins, anything
2066
- # else leaves the config's choice (web.view).
2067
- def view_name(param)
2068
- VIEWS.include?(param) ? param : @view
2069
- end
2070
-
2071
2059
  # The working set for this page load: ?working_set=1|0 wins, anything
2072
2060
  # else leaves the config's choice (web.working_set).
2073
2061
  def working_set?(param)
@@ -10,6 +10,7 @@ require_relative "../message_time"
10
10
  require_relative "../output_formatter"
11
11
  require_relative "../recap_store"
12
12
  require_relative "../ref"
13
+ require_relative "../revise_anchor"
13
14
  require_relative "../steer"
14
15
  require_relative "../turn_note"
15
16
  require_relative "../bridge/card_store"
@@ -70,7 +71,7 @@ module Samagotchi
70
71
 
71
72
  # @param msgs [Array<Hash>] a session's messages; symbol keys from disk,
72
73
  # string keys from a Bridge snapshot
73
- # @param parts [Boolean] the turn view's reload (?parts=1): each
74
+ # @param parts [Boolean] the turn block's reload (?parts=1): each
74
75
  # assistant message also carries what it did (MessageParts: thinking,
75
76
  # tool calls with params and output), and a message with nothing to
76
77
  # show but that (a text-less step) is kept with empty content
@@ -91,6 +92,7 @@ module Samagotchi
91
92
  if Samagotchi::Steer.turn_prompt?(m)
92
93
  note[:turn_start] = true
93
94
  note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
95
+ note.merge!(revises_of(m))
94
96
  end
95
97
  filtered << note
96
98
  next
@@ -135,6 +137,13 @@ module Samagotchi
135
137
  # record by this, not by its place.
136
138
  turn_id = m[:turn_id] || m["turn_id"]
137
139
  message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
140
+ # The turn it revised (a Revise stack, Engine#stamp_opening), and
141
+ # whether it was a Verify turn (the page offers no Revise on its
142
+ # answer).
143
+ if norm_role == "user" && turn_id && !message[:merged]
144
+ message.merge!(revises_of(m))
145
+ message[:verify] = true if Samagotchi::VerifyAnchor.from(m[:verify] || m["verify"])
146
+ end
138
147
  # A participant's message names them (the page's author badge).
139
148
  if norm_role == "user"
140
149
  message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"]))
@@ -166,6 +175,13 @@ module Samagotchi
166
175
  []
167
176
  end
168
177
 
178
+ # A turn opening's Revise stamp as {revises: {turn_id:}}, checked again
179
+ # (a stored file is never trusted), or nothing.
180
+ def revises_of(message)
181
+ anchor = Samagotchi::ReviseAnchor.from(message[:revises] || message["revises"])
182
+ anchor ? { revises: anchor.to_h } : {}
183
+ end
184
+
169
185
  # The entry's wall time (MessageTime: the page's hover and gap
170
186
  # dividers) as {at:}, or nothing for an entry saved without one.
171
187
  def time_of(message)
@@ -272,10 +272,10 @@ module Samagotchi
272
272
  reason = participant_turn_refusal(grant, prompt, body)
273
273
  return participant_turn_refused(grant, prompt, *reason) if reason
274
274
 
275
- verify = VerifyAnchor.from(body["verify"])
275
+ # A Verify turn's claim; never a Revise (the owner's stacks).
276
276
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: ClientId.review(grant.id),
277
277
  delivery: Delivery::QUEUE, author: grant.author,
278
- verify: verify,
278
+ anchors: TurnAnchors.from(body).without_revises,
279
279
  pending_limit: grant.queue_limit + 1,
280
280
  state_dir: @state_dir, manager: @manager,
281
281
  bridge: -> { live_bridge_client(id) })
@@ -4,6 +4,7 @@ require_relative "../output_formatter"
4
4
  require_relative "../steer"
5
5
  require_relative "../turn_origin"
6
6
  require_relative "../participant_mode"
7
+ require_relative "../revise_anchor"
7
8
  require_relative "../verify_anchor"
8
9
 
9
10
  module Samagotchi
@@ -134,6 +135,10 @@ module Samagotchi
134
135
  out = { "type" => field(event, :type).to_s, **id_fields(event) }
135
136
  verdict = verdict_record(field(event, :verdict), viewer)
136
137
  out["verdict"] = verdict if verdict
138
+ # The turn a completed Revise turn revised (as on its prompt; the
139
+ # page doesn't draw stacks yet).
140
+ revises = ReviseAnchor.from(field(event, :revises))
141
+ out["revises"] = revises.to_file if revises
137
142
  out
138
143
  end
139
144
 
@@ -249,6 +254,9 @@ module Samagotchi
249
254
  out["content"] = field(message, :content).to_s
250
255
  out["turn_id"] = turn_id.to_s if turn_id
251
256
  out["merged"] = true if merged
257
+ # The turn it revised (a Revise stack; the page doesn't draw it yet).
258
+ revises = ReviseAnchor.from(field(message, :revises))
259
+ out["revises"] = revises.to_file if revises && turn_id && !merged
252
260
  out.merge!(author_fields(field(message, :author), field(message, :author_name), viewer,
253
261
  field(message, :participant_mode)))
254
262
  images = field(message, :images)
@@ -154,7 +154,7 @@ export function addCompleted(model, event = {}, now = null) {
154
154
  ...(guardrail.judgment_id ? { judgment_id: guardrail.judgment_id } : {}),
155
155
  };
156
156
  }
157
- // The images the tool returned (a reload in the turn view draws them from
157
+ // The images the tool returned (a reload's turn block draws them from
158
158
  // the saved parts, turn_html.js toolRowTemplate).
159
159
  if (event.images?.length) row.images = event.images;
160
160
  // What an edit/write changed in its file (a reload draws it from the
@@ -27,7 +27,7 @@ export function annotationSource(node) {
27
27
  }
28
28
  const thinking = el.closest(".thinking-body");
29
29
  if (thinking) return { kind: "thinking", root: thinking };
30
- // A step's narration in the turn view (the live one streams).
30
+ // A step's narration in a turn block (the live one streams).
31
31
  const step = el.closest(".gen-text");
32
32
  if (step) return step.classList.contains("streaming") ? null : { kind: "step", root: step };
33
33
  const own = el.closest(".bubble.user .user-message");
@@ -0,0 +1,262 @@
1
+ // Folding a long answer by hand (per viewer). A completed answer bubble
2
+ // longer than FOLD_LINES lines or FOLD_CHARS characters of its message text
3
+ // gets a fold row: a sibling just before the bubble, never inside it (the
4
+ // bubble's copy text, its claims and a live end's innerHTML stay the
5
+ // bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
6
+ // no inline style) and the row reads its first line
7
+ // and its size, "‹first line› · N lines ▸"; unfolded, the row stays as a
8
+ // slim "▾ N lines" header.
9
+ //
10
+ // Which answers are folded is this browser's choice (localStorage through
11
+ // storage.js): one key per session holding the folded answers' keys, the
12
+ // last FOLD_KEEP of them. An answer's key is its turn's id
13
+ // (data-answer-turn), a delegate report's reply its own (data-reply-key);
14
+ // a bubble with neither (an answer before any prompt, a canceled turn's
15
+ // partial) gets no row. The keys of sessions gone from the session list go
16
+ // when the list loads (sweepAnswerFolds).
17
+ //
18
+ // One observer on #history decorates every path that fills it (a reload's
19
+ // render, a live turn's end, the stage's hand-off), as copy.js does. A jump
20
+ // into a folded answer unfolds it (history_reveal.js); the row follows the
21
+ // bubble's class.
22
+ //
23
+ // A module of its own: static JS is cached at unversioned URLs, and a new
24
+ // export on an old module would blank the page.
25
+ import { bubbleCopyText } from "./copy.js";
26
+ import { clip } from "./format.js";
27
+ import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
28
+
29
+ // The fold threshold: an answer longer than either gets a fold row.
30
+ export const FOLD_LINES = 25;
31
+ export const FOLD_CHARS = 2000;
32
+ // How many folded answers a session's key keeps (the oldest go first).
33
+ export const FOLD_KEEP = 200;
34
+ export const FOLD_PREFIX = "chi_answer_fold:";
35
+ // A folded row's label, at most this long (CSS cuts it to the row's width).
36
+ const LABEL_CHARS = 160;
37
+
38
+ const ROW_CLASS = "answer-fold";
39
+ const ANSWERS = ".bubble.output";
40
+ // Not done yet: still streaming.
41
+ const UNFINISHED = ".streaming";
42
+
43
+ // +text+'s lines (a trailing newline ends the last one, it adds none).
44
+ export function lineCount(text) {
45
+ const s = String(text ?? "").replace(/\s+$/, "");
46
+ return s ? s.split("\n").length : 0;
47
+ }
48
+
49
+ // Whether an answer of +text+ gets a fold row.
50
+ export function foldable(text) {
51
+ const s = String(text ?? "");
52
+ return lineCount(s) > FOLD_LINES || s.length > FOLD_CHARS;
53
+ }
54
+
55
+ // A markdown line as plain words: no heading marks, list or quote lead, no
56
+ // emphasis or code ticks.
57
+ function plainLine(line) {
58
+ return line
59
+ .replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/, "")
60
+ .replace(/(\*\*|__|`)/g, "")
61
+ .trim();
62
+ }
63
+
64
+ // A folded answer's label: the first line of what a hook presented in its
65
+ // place (+display+: the text is that), else its first markdown heading,
66
+ // else its first text line (a code fence's marks skipped); one line.
67
+ export function foldLabel(text, { display = false } = {}) {
68
+ const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
69
+ const texts = lines.filter((l) => l.trim() && !/^\s*(```|~~~)/.test(l));
70
+ const heading = display ? null : texts.find((l) => /^\s{0,3}#{1,6}\s+\S/.test(l));
71
+ return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
72
+ }
73
+
74
+ // The row's words: folded, the label and the size; unfolded, the size.
75
+ export function foldRowText(text, { display = false, folded = true } = {}) {
76
+ const n = lineCount(text);
77
+ const size = `${n} line${n === 1 ? "" : "s"}`;
78
+ if (!folded) return { label: "", size };
79
+ const label = foldLabel(text, { display });
80
+ return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
81
+ }
82
+
83
+ // ---- The stored choice ----
84
+
85
+ export function foldStoreKey(sessionId) {
86
+ return sessionId ? `${FOLD_PREFIX}${sessionId}` : null;
87
+ }
88
+
89
+ function readEntry(key) {
90
+ try {
91
+ const entry = JSON.parse(readStored(key, "null"));
92
+ if (!entry || !Array.isArray(entry.turns)) return null;
93
+ return { root: typeof entry.root === "string" ? entry.root : null, turns: entry.turns.filter((t) => typeof t === "string") };
94
+ } catch (_) {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // The session's folded answers' keys, oldest first; [] when none or they
100
+ // can't be read.
101
+ export function readFolded(sessionId) {
102
+ const key = foldStoreKey(sessionId);
103
+ return (key && readEntry(key)?.turns) || [];
104
+ }
105
+
106
+ // +list+ with +answerKey+ folded (moved to the end) or not, at most
107
+ // FOLD_KEEP long. Pure.
108
+ export function withFold(list, answerKey, folded) {
109
+ const rest = list.filter((k) => k !== answerKey);
110
+ return (folded ? [...rest, answerKey] : rest).slice(-FOLD_KEEP);
111
+ }
112
+
113
+ // Saves +answerKey+ folded or not for +sessionId+. +root+: the session's
114
+ // project root, kept for the sweep of a project's list.
115
+ export function writeFold(sessionId, answerKey, folded, { root = null } = {}) {
116
+ const key = foldStoreKey(sessionId);
117
+ if (!key || !answerKey) return;
118
+ const turns = withFold(readFolded(sessionId), answerKey, folded);
119
+ if (!turns.length) removeStored(key);
120
+ else writeStored(key, JSON.stringify({ root, turns }));
121
+ }
122
+
123
+ // The session list loaded (+sessions+, each {id, project_root}): the fold
124
+ // keys of sessions not in it go. A project's list (+scoped+) only knows its
125
+ // own project's sessions, so there a key goes only when its project root is
126
+ // one the list shows; an unreadable key goes.
127
+ export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
128
+ const list = Array.isArray(sessions) ? sessions : [];
129
+ const ids = new Set(list.map((s) => s?.id).filter(Boolean));
130
+ const roots = new Set(list.map((s) => s?.project_root).filter(Boolean));
131
+ for (const key of storedKeys(FOLD_PREFIX)) {
132
+ if (ids.has(key.slice(FOLD_PREFIX.length))) continue;
133
+ const entry = readEntry(key);
134
+ if (!entry || !scoped || (entry.root && roots.has(entry.root))) removeStored(key);
135
+ }
136
+ }
137
+
138
+ // ---- The rows ----
139
+
140
+ // The key an answer bubble is folded by, or null.
141
+ export function answerKey(bubble) {
142
+ return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
143
+ }
144
+
145
+ function answerText(bubble) {
146
+ return bubbleCopyText(bubble);
147
+ }
148
+
149
+ function drawRow(row, bubble) {
150
+ const folded = bubble.classList.contains("folded");
151
+ const text = answerText(bubble);
152
+ const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded });
153
+ row.setAttribute("aria-expanded", folded ? "false" : "true");
154
+ row.title = folded ? "Show the answer" : "Fold the answer";
155
+ row.classList.toggle("folded", folded);
156
+ const [labelEl, sizeEl] = row.children;
157
+ if (labelEl.textContent !== label) labelEl.textContent = label;
158
+ if (sizeEl.textContent !== size) sizeEl.textContent = size;
159
+ }
160
+
161
+ function makeRow(doc) {
162
+ const row = doc.createElement("button");
163
+ row.type = "button";
164
+ row.className = ROW_CLASS;
165
+ const label = doc.createElement("span");
166
+ label.className = "answer-fold-label";
167
+ const size = doc.createElement("span");
168
+ size.className = "answer-fold-size";
169
+ row.append(label, size);
170
+ return row;
171
+ }
172
+
173
+ // Runs +mutate+ and scrolls +scroller+ so +row+ stays where it was on
174
+ // screen (folding a long answer above the view would pull the page up), in
175
+ // sight at least.
176
+ export function keepRowInView(scroller, row, mutate) {
177
+ const before = row.getBoundingClientRect().top;
178
+ mutate();
179
+ const after = row.getBoundingClientRect().top;
180
+ if (scroller && after !== before) scroller.scrollTop += after - before;
181
+ const box = row.getBoundingClientRect();
182
+ const view = scroller?.getBoundingClientRect?.();
183
+ if (view && (box.top < view.top || box.bottom > view.bottom)) row.scrollIntoView?.({ block: "nearest" });
184
+ }
185
+
186
+ // @param historyEl the history (#history), also its scroller
187
+ // @param sessionId () => the open session's id
188
+ // @param projectRoot () => the open session's project root, or null
189
+ // @return {decorate(root)} (the observer runs it; specs call it)
190
+ export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
191
+ // Each bubble's row, and each row's bubble.
192
+ const rows = new WeakMap();
193
+ const bubbleOf = new WeakMap();
194
+
195
+ function decorateBubble(bubble, stored) {
196
+ if (bubble.parentElement !== historyEl) return;
197
+ const key = answerKey(bubble);
198
+ let row = rows.get(bubble);
199
+ if (!key || bubble.matches(UNFINISHED) || (!row && !foldable(answerText(bubble)))) return;
200
+ if (!row) {
201
+ row = makeRow(historyEl.ownerDocument);
202
+ rows.set(bubble, row);
203
+ bubbleOf.set(row, bubble);
204
+ // The stored choice, once: after that the bubble's class is the state
205
+ // (a jump that unfolds it, history_reveal.js, isn't saved).
206
+ if (stored().includes(key)) bubble.classList.add("folded");
207
+ }
208
+ if (bubble.previousElementSibling !== row) historyEl.insertBefore(row, bubble);
209
+ drawRow(row, bubble);
210
+ }
211
+
212
+ // Rows whose bubble left (a re-render, a replaced bubble) go with it.
213
+ function dropOrphans() {
214
+ for (const row of historyEl.querySelectorAll(`:scope > .${ROW_CLASS}`)) {
215
+ const bubble = bubbleOf.get(row);
216
+ if (!bubble || bubble.parentElement !== historyEl) row.remove();
217
+ else if (row.nextElementSibling !== bubble) historyEl.insertBefore(row, bubble);
218
+ }
219
+ }
220
+
221
+ function decorate(root) {
222
+ let folded = null;
223
+ const stored = () => (folded ??= readFolded(sessionId()));
224
+ if (root.matches?.(ANSWERS)) decorateBubble(root, stored);
225
+ for (const bubble of root.querySelectorAll?.(ANSWERS) || []) decorateBubble(bubble, stored);
226
+ }
227
+
228
+ // Synchronous (not a frame later, as copy buttons are): the row lands in
229
+ // the same frame as its bubble, so nothing below it shifts after a paint.
230
+ new MutationObserver((records) => {
231
+ const roots = new Set();
232
+ let removed = false;
233
+ for (const r of records) {
234
+ if (r.type === "attributes") {
235
+ if (r.target.matches?.(ANSWERS)) roots.add(r.target);
236
+ continue;
237
+ }
238
+ if (r.removedNodes.length) removed = true;
239
+ const answer = r.target.closest?.(ANSWERS);
240
+ if (answer) roots.add(answer);
241
+ for (const n of r.addedNodes) if (n.nodeType === 1 && !n.classList.contains(ROW_CLASS)) roots.add(n);
242
+ }
243
+ if (removed) dropOrphans();
244
+ for (const el of roots) if (el.isConnected) decorate(el);
245
+ }).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
246
+ decorate(historyEl);
247
+
248
+ historyEl.addEventListener("click", (e) => {
249
+ const row = e.target.closest?.(`.${ROW_CLASS}`);
250
+ const bubble = row && bubbleOf.get(row);
251
+ if (!bubble) return;
252
+ e.preventDefault();
253
+ const folded = !bubble.classList.contains("folded");
254
+ keepRowInView(historyEl, row, () => {
255
+ bubble.classList.toggle("folded", folded);
256
+ drawRow(row, bubble);
257
+ });
258
+ writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
259
+ });
260
+
261
+ return { decorate };
262
+ }
@@ -67,6 +67,8 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
67
67
  if (typeof message.html === "string") {
68
68
  bubble.classList.add("markdown");
69
69
  bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ // A hook's display in the answer's place (answer_fold.js's label).
71
+ bubble.toggleAttribute("data-answer-display", typeof message.display === "string");
70
72
  // The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
71
73
  bubble.innerHTML = message.html;
72
74
  }