samagotchi 0.153.0 → 0.154.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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundles.md +9 -12
  8. data/docs/cli.md +80 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +3 -3
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +1 -1
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-push.md +1 -1
  25. data/docs/memory.md +2 -2
  26. data/docs/plugins.md +23 -28
  27. data/docs/sessions.md +24 -24
  28. data/docs/sub-agent.md +2 -2
  29. data/docs/system-one.md +1 -1
  30. data/docs/testing.md +16 -6
  31. data/lib/samagotchi/answer_text.rb +34 -0
  32. data/lib/samagotchi/bridge.rb +43 -32
  33. data/lib/samagotchi/bridge_client.rb +10 -9
  34. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  35. data/lib/samagotchi/broadcast/triage.rb +1 -1
  36. data/lib/samagotchi/broadcast_command.rb +5 -5
  37. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  38. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  39. data/lib/samagotchi/bundle_command.rb +2 -2
  40. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  41. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  42. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  44. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  45. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  46. data/lib/samagotchi/child_move.rb +3 -3
  47. data/lib/samagotchi/commands/registry.rb +6 -6
  48. data/lib/samagotchi/composer_route.rb +4 -5
  49. data/lib/samagotchi/config.rb +14 -12
  50. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  51. data/lib/samagotchi/context_candidates.rb +4 -3
  52. data/lib/samagotchi/continue_offer.rb +1 -1
  53. data/lib/samagotchi/debug_log.rb +1 -1
  54. data/lib/samagotchi/degrade.rb +75 -0
  55. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  56. data/lib/samagotchi/engine/cards.rb +87 -0
  57. data/lib/samagotchi/engine/context_judge.rb +138 -0
  58. data/lib/samagotchi/engine/events.rb +131 -0
  59. data/lib/samagotchi/engine/guardrails.rb +87 -0
  60. data/lib/samagotchi/engine/hooks.rb +119 -0
  61. data/lib/samagotchi/engine/messages.rb +124 -0
  62. data/lib/samagotchi/engine/model_settings.rb +127 -0
  63. data/lib/samagotchi/engine/models.rb +261 -0
  64. data/lib/samagotchi/engine/objectives.rb +116 -0
  65. data/lib/samagotchi/engine/participants.rb +86 -0
  66. data/lib/samagotchi/engine/plugins.rb +208 -0
  67. data/lib/samagotchi/engine/questions.rb +98 -0
  68. data/lib/samagotchi/engine/recap.rb +130 -0
  69. data/lib/samagotchi/engine/session.rb +131 -0
  70. data/lib/samagotchi/engine/side_requests.rb +135 -0
  71. data/lib/samagotchi/engine/steering.rb +134 -0
  72. data/lib/samagotchi/engine/toolkit.rb +269 -0
  73. data/lib/samagotchi/engine/turn_end.rb +180 -0
  74. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  75. data/lib/samagotchi/engine/verify.rb +153 -0
  76. data/lib/samagotchi/engine.rb +62 -2646
  77. data/lib/samagotchi/grants.rb +575 -0
  78. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  79. data/lib/samagotchi/guardrails/context.rb +2 -3
  80. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  81. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  82. data/lib/samagotchi/hook_routes.rb +3 -3
  83. data/lib/samagotchi/idle_recap.rb +4 -5
  84. data/lib/samagotchi/idle_reminders.rb +3 -4
  85. data/lib/samagotchi/iteration_limit.rb +2 -2
  86. data/lib/samagotchi/kernel_loop.rb +10 -11
  87. data/lib/samagotchi/launch_mode.rb +13 -32
  88. data/lib/samagotchi/level_setting_command.rb +3 -2
  89. data/lib/samagotchi/llm_context_command.rb +3 -2
  90. data/lib/samagotchi/log.rb +7 -7
  91. data/lib/samagotchi/log_line.rb +1 -1
  92. data/lib/samagotchi/log_path.rb +1 -1
  93. data/lib/samagotchi/note_command.rb +1 -1
  94. data/lib/samagotchi/note_delivery.rb +18 -8
  95. data/lib/samagotchi/objectives.rb +24 -3
  96. data/lib/samagotchi/objectives_check.rb +8 -27
  97. data/lib/samagotchi/objectives_command.rb +4 -69
  98. data/lib/samagotchi/objectives_edit.rb +87 -0
  99. data/lib/samagotchi/output_formatter.rb +7 -1
  100. data/lib/samagotchi/owner_lock.rb +7 -1
  101. data/lib/samagotchi/parent_answers_command.rb +5 -4
  102. data/lib/samagotchi/participant_exposure.rb +42 -0
  103. data/lib/samagotchi/participant_mode.rb +1 -1
  104. data/lib/samagotchi/participant_name.rb +1 -1
  105. data/lib/samagotchi/participants.rb +17 -17
  106. data/lib/samagotchi/participants_watch.rb +1 -1
  107. data/lib/samagotchi/pending_input_queue.rb +2 -2
  108. data/lib/samagotchi/plugin/api.rb +3 -2
  109. data/lib/samagotchi/plugin/context.rb +21 -7
  110. data/lib/samagotchi/plugin/peer.rb +2 -1
  111. data/lib/samagotchi/plugin/sessions.rb +6 -4
  112. data/lib/samagotchi/plugin_tasks.rb +1 -2
  113. data/lib/samagotchi/private_files.rb +29 -0
  114. data/lib/samagotchi/project_env.rb +2 -2
  115. data/lib/samagotchi/question_desk.rb +8 -7
  116. data/lib/samagotchi/recap_command.rb +2 -1
  117. data/lib/samagotchi/recap_setup.rb +1 -1
  118. data/lib/samagotchi/recap_store.rb +1 -1
  119. data/lib/samagotchi/redact.rb +86 -0
  120. data/lib/samagotchi/reminder_queue.rb +2 -2
  121. data/lib/samagotchi/remote_attach_command.rb +1 -1
  122. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  123. data/lib/samagotchi/review_start.rb +1 -1
  124. data/lib/samagotchi/run_options.rb +6 -6
  125. data/lib/samagotchi/self_report.rb +42 -39
  126. data/lib/samagotchi/send_command/busy.rb +1 -1
  127. data/lib/samagotchi/send_command.rb +4 -1
  128. data/lib/samagotchi/session.rb +11 -2
  129. data/lib/samagotchi/session_access/contract.rb +3 -3
  130. data/lib/samagotchi/session_access/local.rb +9 -9
  131. data/lib/samagotchi/session_access/remote.rb +17 -7
  132. data/lib/samagotchi/session_access.rb +4 -4
  133. data/lib/samagotchi/session_archive_command.rb +2 -2
  134. data/lib/samagotchi/session_chain.rb +8 -6
  135. data/lib/samagotchi/session_commands.rb +45 -48
  136. data/lib/samagotchi/session_continue.rb +1 -1
  137. data/lib/samagotchi/session_delete_command.rb +4 -4
  138. data/lib/samagotchi/session_manager.rb +59 -36
  139. data/lib/samagotchi/session_metrics.rb +2 -2
  140. data/lib/samagotchi/session_model_settings.rb +3 -2
  141. data/lib/samagotchi/session_participants.rb +1 -1
  142. data/lib/samagotchi/session_retention.rb +3 -3
  143. data/lib/samagotchi/sessions_command.rb +4 -4
  144. data/lib/samagotchi/share_command.rb +10 -10
  145. data/lib/samagotchi/steer.rb +1 -1
  146. data/lib/samagotchi/system_prompt.rb +102 -109
  147. data/lib/samagotchi/task_watch.rb +1 -1
  148. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  149. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  150. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  151. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  152. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  153. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  154. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  155. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  156. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  157. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  158. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  159. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  160. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  161. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  163. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  164. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  165. data/lib/samagotchi/terminal_ui.rb +142 -1387
  166. data/lib/samagotchi/title_command.rb +2 -2
  167. data/lib/samagotchi/tool_runner.rb +14 -16
  168. data/lib/samagotchi/toolkit_command.rb +2 -1
  169. data/lib/samagotchi/tools/delegate.rb +3 -1
  170. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  171. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  172. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  173. data/lib/samagotchi/tools/send_note.rb +2 -1
  174. data/lib/samagotchi/turn_endings.rb +2 -2
  175. data/lib/samagotchi/turn_flow.rb +4 -5
  176. data/lib/samagotchi/update_hint.rb +2 -2
  177. data/lib/samagotchi/version.rb +1 -1
  178. data/lib/samagotchi/web/app.rb +39 -50
  179. data/lib/samagotchi/web/participant_access.rb +2 -2
  180. data/lib/samagotchi/web/participant_review.rb +5 -5
  181. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  182. data/lib/samagotchi/web/participant_view.rb +2 -82
  183. data/lib/samagotchi/web/participants_admin.rb +1 -1
  184. data/lib/samagotchi/web/public/block_host.js +35 -8
  185. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  186. data/lib/samagotchi/web/public/chains.js +2 -2
  187. data/lib/samagotchi/web/public/claim_link.js +4 -4
  188. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  189. data/lib/samagotchi/web/public/format.js +3 -3
  190. data/lib/samagotchi/web/push/store.rb +1 -1
  191. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  192. data/lib/samagotchi/web/session_control.rb +12 -8
  193. data/lib/samagotchi/web/session_create.rb +1 -1
  194. data/lib/samagotchi/web/session_hub.rb +2 -2
  195. data/lib/samagotchi/web/session_show.rb +2 -2
  196. data/lib/samagotchi/web/session_summary.rb +1 -1
  197. data/lib/samagotchi/worker.rb +100 -9
  198. data/lib/samagotchi/worker_link.rb +3 -3
  199. data/lib/samagotchi/working_set_command.rb +3 -2
  200. metadata +29 -6
  201. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  202. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  203. data/lib/samagotchi/web/grants.rb +0 -575
  204. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  205. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Secret shapes and host paths out of what a participant gets (the
5
+ # session's folder as relative paths, home as ~). Defence in depth: the
6
+ # allowlist (Web::ParticipantView) is the control; this catches a key the
7
+ # model quoted in a reply, or one in a prompt. Also how the Engine reads a
8
+ # Verify quote and ObjectivesCheck a text: as a participant's page shows it.
9
+ # Pure, stdlib only: core files use it without loading chi web.
10
+ module Redact
11
+ # Who is looking: +author+ the participant's ("p:<id>"; nil for the
12
+ # owner through the participant view: nothing is theirs), +cwd+ the
13
+ # session's folder (tool paths are shown relative to it, only inside
14
+ # it), +home+ the folder shown as ~, +secrets+ literal strings never
15
+ # shown (the owner's token).
16
+ Viewer = Data.define(:author, :cwd, :home, :secrets) do
17
+ def initialize(author: nil, cwd: nil, home: Dir.home, secrets: [])
18
+ super(author: author, cwd: cwd, home: home, secrets: Array(secrets).map(&:to_s).reject(&:empty?))
19
+ end
20
+
21
+ def own?(author) = !self.author.nil? && author.to_s == self.author
22
+ end
23
+
24
+ MARK = "[redacted]"
25
+ PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)/m
26
+ PATTERNS = [
27
+ PRIVATE_KEY,
28
+ /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/,
29
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/,
30
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/,
31
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
32
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
33
+ /\bchp_[A-Za-z0-9_-]{20,}/,
34
+ # A one-time link code (LinkCodes::CODE_RE): a bearer credential
35
+ # until its grant expires (an owner's, 10 minutes).
36
+ /\bchc_[A-Za-z0-9_-]{43}/,
37
+ /\bAIza[0-9A-Za-z_-]{30,}/,
38
+ /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/,
39
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
40
+ %r{https://hooks\.slack\.com/services/[A-Za-z0-9/_-]+}
41
+ ].freeze
42
+ # The value after an Authorization (or a Bridge's secret,
43
+ # BridgeSecret::HEADER) header or a token=/key= pair.
44
+ VALUES = [
45
+ /(\b(?:Authorization|X-Chi-Bridge):\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/i,
46
+ /((?<![A-Za-z0-9])[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*[=:]\s*["']?)[^\s"'&`,;}]+/i,
47
+ # A URL's password: scheme://user:PASSWORD@host.
48
+ %r{(\b[a-z][a-z0-9+.-]*://[^/\s:@]+:)[^@\s/]+(?=@)}i
49
+ ].freeze
50
+ # A text that ends where one of VALUES' values (or a private key's
51
+ # BEGIN line) would go on: a stream cut there would send the value
52
+ # on its own, unredacted (ParticipantStreamFilter never cuts there).
53
+ OPEN_VALUE = /(?:\b(?:Authorization|X-Chi-Bridge):\s*(?:(?:Bearer|Basic|Token)\s*)?|[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*(?:[=:]\s*["']?)?|-----BEGIN [A-Z0-9 ]*)\z/i
54
+
55
+ module_function
56
+
57
+ # +value+ with every string in it redacted (hash values, array items).
58
+ def deep(value, viewer)
59
+ case value
60
+ when String then text(value, viewer)
61
+ when Hash then value.transform_values { |item| deep(item, viewer) }
62
+ when Array then value.map { |item| deep(item, viewer) }
63
+ else value
64
+ end
65
+ end
66
+
67
+ def text(value, viewer)
68
+ out = value.dup
69
+ viewer.secrets.each { |secret| out = out.gsub(secret, MARK) }
70
+ PATTERNS.each { |pattern| out = out.gsub(pattern, MARK) }
71
+ VALUES.each { |pattern| out = out.gsub(pattern) { "#{Regexp.last_match(1)}#{MARK}" } }
72
+ out = within(out, viewer.cwd.to_s)
73
+ home = viewer.home.to_s
74
+ out = out.gsub(%r{#{Regexp.escape(home)}(?=/|\b|\z)}, "~") if home.length > 1
75
+ out
76
+ end
77
+
78
+ # +text+ with +dir+ (the session's folder) shown relative: "dir/x"
79
+ # as "x", "dir" alone as ".".
80
+ def within(text, dir)
81
+ return text if dir.length < 2
82
+
83
+ text.gsub(%r{#{Regexp.escape(dir)}(/|(?=\b|\z))}) { Regexp.last_match(1) == "/" ? "" : "." }
84
+ end
85
+ end
86
+ end
@@ -8,8 +8,8 @@ module Samagotchi
8
8
  #
9
9
  # The ReminderStore is the truth (a reminder is due once its interval
10
10
  # passed). This adds the names the idle tick found due and handed to the
11
- # UI's callback (#note_pending): the worker and the REPL run a reminder
12
- # turn while they are pending. A turn injects whatever is due then
11
+ # UI's callback (#note_pending): the worker runs a reminder turn while
12
+ # they are pending. A turn injects whatever is due then
13
13
  # (#inject!), which marks it fired and clears the pending names.
14
14
  class ReminderQueue
15
15
  # The reminders message's first line at +time+ (a Time in the zone to
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  # attached TUI on a session of another machine, through its chi web
16
16
  # (bin/chi branches here after RunOptions.parse, before its own checks,
17
17
  # which are this machine's). The run flags come parsed; the ones that
18
- # run this machine's REPL or read its files were refused already
18
+ # run this machine's Engine or read its files were refused already
19
19
  # (SessionAccess::Contract::RUN). The setup flags go as typed: that
20
20
  # machine checks them against its own config and bundles.
21
21
  class RemoteAttachCommand
@@ -160,7 +160,7 @@ module Samagotchi
160
160
  end
161
161
 
162
162
  # Only a question a live worker holds waits for an answer (the lists'
163
- # rule): one a dead worker left waits for no one, and a chi REPL's is
163
+ # rule): one a dead worker left waits for no one, and a one-turn chi run's is
164
164
  # answered there.
165
165
  def question(session)
166
166
  pending = session.pending_question
@@ -171,7 +171,7 @@ module Samagotchi
171
171
  Result.new(status: :waiting_for_answer, question: pending, session: session)
172
172
  end
173
173
 
174
- # A worker holds the session's lock (a chi REPL's question is its own).
174
+ # A worker holds the session's lock (a one-turn chi run's question is its own).
175
175
  def worker_owned?
176
176
  WorkerLink.resolve(Session.session_dir(@id, state_dir: @state_dir), unlink: false).worker_owned?
177
177
  end
@@ -143,7 +143,7 @@ module Samagotchi
143
143
  end
144
144
 
145
145
  # What inviting a participant to +session+ now means: the grant's mode
146
- # (Web::Grants::PAIR_MODE only when the owner is shown PAIR_WARNING: a
146
+ # (Grants::PAIR_MODE only when the owner is shown PAIR_WARNING: a
147
147
  # working session whose live worker runs pair turns; nil otherwise, a
148
148
  # review participant), the mode their turns get (:pair or :review) and
149
149
  # the warning shown for it (nil for a review session). One read of the
@@ -14,7 +14,7 @@ module Samagotchi
14
14
  module RunOptions
15
15
  # The flags an underscored spelling is refused for (--recap_base_url),
16
16
  # with the config registry's.
17
- KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel --start-dir].freeze
17
+ KNOWN = %w[--prompt --resume --attach --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel --start-dir].freeze
18
18
 
19
19
  # An OptionParser that also records each switch it took, as the
20
20
  # normalized [flag] or [flag, value] (a --[no-]x as --x or --no-x; an
@@ -221,12 +221,11 @@ module Samagotchi
221
221
  (each subcommand takes --help)
222
222
 
223
223
  BANNER
224
- opts.on("-p", "--prompt PROMPT", "Run PROMPT as the first turn, then stay in the session (attached; the REPL with --no-shared)") { |p| options[:prompt] = p }
224
+ opts.on("-p", "--prompt PROMPT", "Run PROMPT as the first turn, then stay in the session (attached; one turn and out with --non-interactive)") { |p| options[:prompt] = p }
225
225
  opts.on("--resume SESSION_ID", "Resume a previous session by its ID (or a unique prefix of it)") { |s| options[:resume] = s }
226
226
  opts.on("--attach SESSION_ID", "Attach to a session's worker, waking one if it has exited; ID or a unique prefix") { |s| options[:attach] = s }
227
227
  opts.on("--dir DIR", "With --remote: a new session's folder on that machine, its absolute path (required there; " \
228
228
  "here cd instead)") { |d| options[:dir] = d }
229
- opts.on("--[no-]shared", "Run the session in a background worker and attach to it, so `chi web` can share it (the default, config session.shared; with --resume: that session); --no-shared runs a plain in-process REPL") { |v| options[:shared] = v }
230
229
  opts.on("--model NAME", "Model for this run, not saved (overrides --resume's; config default.model) [env SAMAGOTCHI_DEFAULT_MODEL]") { |m| options[:model] = m; cli_overrides["default.model"] = m }
231
230
  opts.on("--profile NAME", Samagotchi::ModelProfile::NAMES,
232
231
  "Prompt profile for every model in this run (#{Samagotchi::ModelProfile::NAMES.join("|")}) [alias for --model-profile]") { |p| cli_overrides["model.profile"] = p }
@@ -260,9 +259,10 @@ module Samagotchi
260
259
  opts.on("--llm-context-apply RULE", "This session's own apply rule for it: payoff|next_request|turn_end") { |v| (options[:llm_context] ||= {})[:apply] = v }
261
260
  opts.on("--llm-context-budget TOKENS", "This session's own context budget: tokens (64000, 64k) or off") { |v| (options[:llm_context] ||= {})[:budget_tokens] = v }
262
261
  opts.on("--no-interrupt", "Raise the tool call limit to 1000 iterations (useful for long tasks)") { options[:no_interrupt] = true; cli_overrides["no_interrupt"] = true }
263
- opts.on("--non-interactive", "Run a single prompt and exit (no REPL, implies --no-interrupt)") { options[:non_interactive] = true }
264
- opts.on("-v", "--verbose", "Print raw LLM responses and tool calls/results (runs a plain REPL, not attached)") { options[:verbose] = true }
265
- opts.on("--no-default-input", "Skip default REPL input prefill") { options[:no_default_input] = true; cli_overrides["no_default_input"] = true }
262
+ opts.on("--non-interactive", "Run the -p prompt in this process, one turn, and exit (implies --no-interrupt)") { options[:non_interactive] = true }
263
+ opts.on("-v", "--verbose", "Show the session's debug records (raw LLM responses, tool calls/results) as they come; " \
264
+ "a worker this chi starts logs at debug (with --non-interactive: on stderr)") { options[:verbose] = true }
265
+ opts.on("--no-default-input", "Skip the default input typed into a new session's first read (SAMAGOTCHI_DEFAULT_INPUT)") { options[:no_default_input] = true; cli_overrides["no_default_input"] = true }
266
266
  opts.on("--port PORT", "Port for `chi web` (default: 4567, env SAMAGOTCHI_WEB_PORT)") { |p| options[:web_port] = p.to_i; cli_overrides["web.port"] = p.to_s }
267
267
  opts.on("--open", "Open browser for `chi web`") { options[:web_open] = true }
268
268
  opts.on("--new-token", "`chi web`: replace the LAN access token (web.host: lan); links and QR codes made with the old one stop working") { options[:new_token] = true }
@@ -3,6 +3,7 @@
3
3
  require_relative "version"
4
4
  require_relative "config"
5
5
  require_relative "context_window"
6
+ require_relative "degrade"
6
7
  require_relative "session"
7
8
  require_relative "log_path"
8
9
  require_relative "model_profile"
@@ -177,19 +178,19 @@ module Samagotchi
177
178
 
178
179
  # [tokens, "models: KEY" | "hosts.NAME"], or nil when neither is set.
179
180
  def model_window(env, model)
180
- return nil unless model
181
-
182
- registry = HostRegistry.new(env: env)
183
- target = registry.resolve(model)
184
- names = registry.lookup_names(model, target: target)
185
- models = ConfigFile.model_settings(env: env)
186
- window = ContextWindow.setting(target, names: names, models: models)
187
- return nil unless window
188
- return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
189
-
190
- [window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).key}"]
191
- rescue StandardError
192
- nil
181
+ Degrade.call(:config, "model_window_unreadable") do
182
+ return nil unless model
183
+
184
+ registry = HostRegistry.new(env: env)
185
+ target = registry.resolve(model)
186
+ names = registry.lookup_names(model, target: target)
187
+ models = ConfigFile.model_settings(env: env)
188
+ window = ContextWindow.setting(target, names: names, models: models)
189
+ return nil unless window
190
+ return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
191
+
192
+ [window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).key}"]
193
+ end
193
194
  end
194
195
 
195
196
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
@@ -220,11 +221,11 @@ module Samagotchi
220
221
  end
221
222
 
222
223
  def same_model?(one, other, env)
223
- return true if one == other
224
+ Degrade.call(:config, "model_ref_unresolved", fallback: false) do
225
+ return true if one == other
224
226
 
225
- ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
226
- rescue StandardError
227
- false
227
+ ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
228
+ end
228
229
  end
229
230
 
230
231
  # The memory overlay key (ModelOverlay.key_for the bare id, as the
@@ -257,41 +258,41 @@ module Samagotchi
257
258
  # session chi self runs in has one (an alias), else of the name chi self
258
259
  # reports. nil when that is the key itself. Read-only, never raising.
259
260
  def fallback_model_key(model, key, env)
260
- typed = session_typed_model(env) || model
261
- typed_key = ModelOverlay.key_for(HostRegistry.new(env: env).parse_qualified_model(typed).last)
262
- typed_key == key ? nil : typed_key
263
- rescue StandardError
264
- nil
261
+ Degrade.call(:model, "fallback_key_unresolved") do
262
+ typed = session_typed_model(env) || model
263
+ typed_key = ModelOverlay.key_for(HostRegistry.new(env: env).parse_qualified_model(typed).last)
264
+ typed_key == key ? nil : typed_key
265
+ end
265
266
  end
266
267
 
267
268
  # The model name the session chi self runs in was typed as (an alias),
268
269
  # its model_typed, else nil.
269
270
  def session_typed_model(env)
270
- id = env[PARENT_SESSION_ENV].to_s
271
- return nil if id.empty? || !Session.valid_id?(id)
271
+ Degrade.call(:config, "session_typed_model_unreadable") do
272
+ id = env[PARENT_SESSION_ENV].to_s
273
+ return nil if id.empty? || !Session.valid_id?(id)
272
274
 
273
- state_dir = Session.default_state_dir(env: env)
274
- return nil unless Session.exist?(id, state_dir: state_dir)
275
+ state_dir = Session.default_state_dir(env: env)
276
+ return nil unless Session.exist?(id, state_dir: state_dir)
275
277
 
276
- session = Session.load(id, state_dir: state_dir)
277
- typed = session.model_typed.to_s.strip
278
- typed.empty? ? nil : typed
279
- rescue StandardError
280
- nil
278
+ session = Session.load(id, state_dir: state_dir)
279
+ typed = session.model_typed.to_s.strip
280
+ typed.empty? ? nil : typed
281
+ end
281
282
  end
282
283
 
283
284
  # The --mute list of the session chi self runs in (its execute exports
284
285
  # SAMAGOTCHI_PARENT_SESSION); [] outside one.
285
286
  def session_muted(env)
286
- id = env[PARENT_SESSION_ENV].to_s
287
- return [] if id.empty? || !Session.valid_id?(id)
287
+ Degrade.call(:config, "session_mutes_unreadable", fallback: []) do
288
+ id = env[PARENT_SESSION_ENV].to_s
289
+ return [] if id.empty? || !Session.valid_id?(id)
288
290
 
289
- state_dir = Session.default_state_dir(env: env)
290
- return [] unless Session.exist?(id, state_dir: state_dir)
291
+ state_dir = Session.default_state_dir(env: env)
292
+ return [] unless Session.exist?(id, state_dir: state_dir)
291
293
 
292
- MutedMemories.normalize_list(Session.load(id, state_dir: state_dir).muted_memory_names)
293
- rescue StandardError
294
- []
294
+ MutedMemories.normalize_list(Session.load(id, state_dir: state_dir).muted_memory_names)
295
+ end
295
296
  end
296
297
 
297
298
  def model_name
@@ -489,7 +490,9 @@ module Samagotchi
489
490
 
490
491
  # "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"
491
492
  def bundles_summary
492
- shipped = MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version rescue nil
493
+ shipped = Degrade.call(:memory, "shipped_bundle_unreadable") do
494
+ MemoryBundle::Manifest.read(dir: MemoryBundle::SystemBundle::GEM_BUNDLE_DIR).version
495
+ end
493
496
  installed = MemoryBundle::Provenance.each_installed.map do |name, bundle|
494
497
  version = bundle.version || "?"
495
498
  label = "#{name} #{version}"
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  # parent_answers
24
24
  Busy = Data.define(:error, :session_id, :question, :strategy) do
25
25
  # @param session [Session] the session as just read
26
- # @param owner [OwnerLock::Owner, nil] the worker or REPL that owns it
26
+ # @param owner [OwnerLock::Owner, nil] the worker or one-shot that owns it
27
27
  # @param queue [Boolean] chi send --queue
28
28
  # @return [Busy, nil] nil when the message may go in
29
29
  def self.for(session, owner:, queue:)
@@ -799,8 +799,11 @@ module Samagotchi
799
799
  end
800
800
  @info.puts("#{short} sent#{with_images}#{note}")
801
801
  true
802
+ rescue SessionManager::ScratchSession => e
803
+ @info.puts("#{short} refused: #{e.message}")
804
+ false
802
805
  rescue SessionManager::OwnedByTUI
803
- @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
806
+ @info.puts("#{short} refused: it is #{OwnerLock::TUI_BUSY}; send once it ends")
804
807
  false
805
808
  rescue StandardError => e
806
809
  @info.puts("#{short} failed: #{e.message}")
@@ -38,6 +38,11 @@ module Samagotchi
38
38
  STATUS_ERROR = "error"
39
39
  STATUS_STOPPED = "stopped"
40
40
  TOOLKIT_ORIGINS = %w[chosen default inherited suggested].freeze
41
+ # Why anything but the terminal that started a `chi scratch` session is
42
+ # refused it (chi --attach/--resume, chi send, chi web's wake, turn and
43
+ # command routes): one wording for a live one and a leftover one.
44
+ SCRATCH_REFUSED = "that's a chi scratch session: only the terminal that started it uses it " \
45
+ "(a leftover one is deleted at the next sweep: chi sessions clean)"
41
46
 
42
47
  # A stopped session's marker, <session dir>/stopped: a stop (chi
43
48
  # sessions stop, from another process) creates it instead of rewriting
@@ -54,6 +59,9 @@ module Samagotchi
54
59
  # The memory a delegate child starts with (Tools::Delegate::CHILD_MEMORIES).
55
60
  DELEGATE_MEMORY = "system/delegated"
56
61
 
62
+ # What a command answers when there is no session to act on yet.
63
+ NONE_YET = "no session yet"
64
+
57
65
  SORT_KEYS = %w[created_at updated_at].freeze
58
66
  SORT_ORDERS = %w[asc desc].freeze
59
67
 
@@ -116,8 +124,9 @@ module Samagotchi
116
124
  # nil. Not a parent: archiving one link leaves the next alone. The chain
117
125
  # itself is derived (SessionChain), never stored.
118
126
  attr_accessor :continues
119
- # A `chi scratch` session: deleted when its REPL ends, and by the next
120
- # sweep (or `chi sessions clean`) when the process died first.
127
+ # A `chi scratch` session: deleted when its terminal leaves (its worker
128
+ # deletes it too once its last client is gone), and by the next sweep
129
+ # (or `chi sessions clean`) when both died first.
121
130
  attr_accessor :scratch
122
131
  # The question this session waits on (PendingQuestion), saved as
123
132
  # session.json's "pending_question"; nil with none. A Hash (a saved
@@ -149,14 +149,14 @@ module Samagotchi
149
149
 
150
150
  # bin/chi's run flags under a remote (chi --remote NAME --attach ID,
151
151
  # RemoteAttachCommand): what a remote attach takes, and what runs
152
- # this machine's REPL or reads this machine's files (P4-D2..D4). A
152
+ # this machine's Engine or reads this machine's files (P4-D2..D4). A
153
153
  # config registry flag (--recap-base-url …) is this machine's
154
154
  # config: LOCAL_ONLY without being listed.
155
155
  RUN = {
156
- SUPPORTED => %w[--attach --resume -p --prompt --shared --model --thinking --llm-context --llm-context-apply
156
+ SUPPORTED => %w[--attach --resume -p --prompt --model --thinking --llm-context --llm-context-apply
157
157
  --llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
158
158
  --no-default-input --dir --version -h --help],
159
- LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
159
+ LOCAL_ONLY => %w[--non-interactive -v --verbose --append-system-prompt --memory --mute --profile
160
160
  --port --open --new-token --tunnel --start-dir --scope --web-markdown --no-web-markdown]
161
161
  }.freeze
162
162
  # A short run flag's name in a refusal.
@@ -23,7 +23,7 @@ require_relative "../launch_mode"
23
23
  require_relative "../participants"
24
24
  require_relative "../config"
25
25
  require_relative "../live_versions"
26
- require_relative "../web/grants"
26
+ require_relative "../grants"
27
27
  require_relative "../web/info_probe"
28
28
  require_relative "../web/public_url"
29
29
  require_relative "../web/link_codes"
@@ -76,7 +76,7 @@ module Samagotchi
76
76
  # @raise [ArgumentError] no such session
77
77
  def show(id) = Session.load(id, **dir)
78
78
 
79
- # @return [OwnerLock::Owner, nil] the worker or chi REPL that owns it
79
+ # @return [OwnerLock::Owner, nil] the worker or one-turn chi run that owns it
80
80
  def owner(id) = SessionManager.session_owner(id, **dir)
81
81
 
82
82
  # SessionManager.list_sessions: Session objects, for chi sessions list.
@@ -94,11 +94,11 @@ module Samagotchi
94
94
  # a margin), so the resume finds the session free.
95
95
  # @return [Woken] its sidecar nil when the Bridge didn't come in time
96
96
  # @raise [ArgumentError] no such session
97
- # @raise [Refused] code scratch: a leftover chi scratch session
98
- # @raise [SessionManager::OwnedByTUI] a chi REPL has it
97
+ # @raise [Refused] code scratch: a chi scratch session (its terminal's alone)
98
+ # @raise [SessionManager::OwnedByTUI] a one-turn chi run has it
99
99
  def wake(id, wait:)
100
100
  session = show(id)
101
- raise Refused.new(LaunchMode::SCRATCH_LEFTOVER, code: "scratch") if session.scratch
101
+ raise Refused.new(Session::SCRATCH_REFUSED, code: "scratch") if session.scratch
102
102
 
103
103
  link = WorkerLink.resolve(session_dir(id), unlink: true)
104
104
  case link
@@ -177,7 +177,7 @@ module Samagotchi
177
177
  end
178
178
 
179
179
  # @return [SessionManager::DeliverResult]
180
- # @raise [SessionManager::OwnedByTUI] a chi REPL owns it
180
+ # @raise [SessionManager::OwnedByTUI] a one-turn chi run owns it
181
181
  def deliver_turn(id, prompt:, client_id:, images:, delivery: nil)
182
182
  SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id, images: images, delivery: delivery, **dir)
183
183
  end
@@ -422,7 +422,7 @@ module Samagotchi
422
422
  Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
423
423
  link: link, lan: !public_url.nil? || !web.ip.nil?,
424
424
  warning: terms.warning)
425
- rescue Web::Grants::Invalid => e
425
+ rescue Grants::Invalid => e
426
426
  raise BadRequest.new(e.message, code: "invalid_participant")
427
427
  end
428
428
 
@@ -446,13 +446,13 @@ module Samagotchi
446
446
  raise BadRequest.new(Participants.no_participant(participant, id), code: "invalid_participant") unless grant
447
447
 
448
448
  ParticipantView.from_h(grant.public_h(grants.now))
449
- rescue Web::Grants::Invalid => e
449
+ rescue Grants::Invalid => e
450
450
  raise BadRequest.new(e.message, code: "invalid_participant")
451
451
  end
452
452
 
453
453
  private
454
454
 
455
- def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
455
+ def grants = @grants ||= Grants.new(Grants.path(state_dir: state_dir!))
456
456
 
457
457
  LOCAL_PROBE_TIMEOUT = 1.0
458
458
 
@@ -12,7 +12,7 @@ require_relative "../bridge_client"
12
12
  require_relative "../session_continue"
13
13
  require_relative "../guardrails/parent_policy"
14
14
  require_relative "../model_profile"
15
- require_relative "../web/grants"
15
+ require_relative "../grants"
16
16
  require_relative "http_transport"
17
17
  require_relative "../chi_invocation"
18
18
 
@@ -59,7 +59,7 @@ module Samagotchi
59
59
  def remote? = true
60
60
  def name = @entry.name
61
61
 
62
- # Web::Grants::PREFIX, without loading chi web.
62
+ # Grants::PREFIX, without loading chi web.
63
63
  PARTICIPANT_PREFIX = "chp_"
64
64
 
65
65
  # Whether the token is a participant's (chi share's link), not the
@@ -229,7 +229,12 @@ module Samagotchi
229
229
  SessionManager::DeliverResult.new(status: :accepted, via: via, ack: ack.except("via"))
230
230
  when 504 then SessionManager::DeliverResult.new(status: :timeout, ack: { "detail" => ack["detail"] })
231
231
  when 409
232
- raise SessionManager::OwnedByTUI, ack["session_id"] || id if ack["error"] == "owned_by_tui"
232
+ if ack["error"] == "owned_by_tui"
233
+ # A chi scratch session (scratch: true; an older chi web sends none).
234
+ raise SessionManager::ScratchSession, ack["session_id"] || id if ack["scratch"] == true
235
+
236
+ raise SessionManager::OwnedByTUI, ack["session_id"] || id
237
+ end
233
238
 
234
239
  SessionManager::DeliverResult.new(status: :refused, code: 409, ack: ack)
235
240
  when 404 then raise_refusal(reply)
@@ -316,7 +321,9 @@ module Samagotchi
316
321
 
317
322
  NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
318
323
  when 409
319
- return NoteDelivery::Result.new(id: id, status: :refused, queued: 0) if json["error"] == "owned_by_tui"
324
+ if json["error"] == "owned_by_tui"
325
+ return NoteDelivery::Result.new(id: id, status: :refused, queued: 0, scratch: json["scratch"] == true)
326
+ end
320
327
 
321
328
  raise_refusal(reply)
322
329
  when 400
@@ -584,7 +591,7 @@ module Samagotchi
584
591
  # when none runs, waited for up to +wait+ seconds there (a leaving
585
592
  # worker first: +read_timeout+ covers that wait too).
586
593
  # @return [WakeReply]
587
- # @raise [SessionManager::OwnedByTUI] a chi REPL has it there
594
+ # @raise [SessionManager::OwnedByTUI] a one-turn chi run has it there
588
595
  # @raise [Refused] a leftover scratch session (code scratch), or its
589
596
  # Bridge didn't come in time (code worker_timeout): chi web's words,
590
597
  # LaunchMode's constants
@@ -709,7 +716,10 @@ module Samagotchi
709
716
  case error
710
717
  when "not_found" then raise ArgumentError, message.to_s
711
718
  when "ambiguous_id" then raise Session::AmbiguousId.new(message, ids: Array(json["ids"]))
712
- when "owned_by_tui" then raise SessionManager::OwnedByTUI, sid
719
+ when "owned_by_tui"
720
+ raise SessionManager::ScratchSession, sid if json["scratch"] == true
721
+
722
+ raise SessionManager::OwnedByTUI, sid
713
723
  when "worker_running", "still_stopping" then raise SessionManager::DeleteRefused.new(sid, reason)
714
724
  when "busy", "scratch"
715
725
  raise SessionManager::ArchiveRefused.new(sid, reason, busy_id: json["busy_id"]) if reason
@@ -728,7 +738,7 @@ module Samagotchi
728
738
  # A toolkit whose config kit's groups: is invalid there: the same.
729
739
  when "toolkit_refused" then raise Toolkit::Refused, message.to_s
730
740
  # The grants store doesn't read there: chi share says what it says here.
731
- when "grants_unwritable" then raise Web::Grants::Unwritable, message.to_s
741
+ when "grants_unwritable" then raise Grants::Unwritable, message.to_s
732
742
  when /\Ainvalid_/, "missing_fields", "bad_images"
733
743
  # invalid_participant: chi share's name, kind or ttl, an archived session
734
744
  raise BadRequest.new(message.to_s, code: error)
@@ -98,7 +98,7 @@ module Samagotchi
98
98
  class BadRequest < Refused; end
99
99
 
100
100
  # One participant's grant as chi share ls shows it
101
- # (Web::Grants::Grant#public_h): never its token or digest. +state+
101
+ # (Grants::Grant#public_h): never its token or digest. +state+
102
102
  # "active", "revoked" or "expired".
103
103
  # +cap+ dollars and +queue+ turns (nil from a chi web before them).
104
104
  # +identity+ the email the grant is bound to, nil for any.
@@ -163,7 +163,7 @@ module Samagotchi
163
163
  # A session as a session command reads it (Local: the Session itself;
164
164
  # Remote: chi web's brief read). The fields the commands use, and
165
165
  # Session's #display_title.
166
- # @!attribute owner [OwnerLock::Owner, nil] its worker or chi REPL
166
+ # @!attribute owner [OwnerLock::Owner, nil] its worker or one-turn chi run
167
167
  # @!attribute pending_question [PendingQuestion, nil] the file's
168
168
  SessionView = Data.define(:id, :status, :title, :display_title, :project_root, :working_directory, :model_name,
169
169
  :model_typed, :parent_answers, :owner, :pending_question)
@@ -189,7 +189,7 @@ module Samagotchi
189
189
  # features, epoch; nil: none answers)
190
190
  def sidecar = link.live? ? link.sidecar : nil
191
191
 
192
- # @return [OwnerLock::Owner, nil] a worker, a chi REPL, nil
192
+ # @return [OwnerLock::Owner, nil] a worker, a one-turn chi run, nil
193
193
  def owner = link.owner
194
194
 
195
195
  def live? = link.live?
@@ -238,7 +238,7 @@ module Samagotchi
238
238
  # @!attribute known_turn [Boolean, nil] with a turn asked for
239
239
  # (--turn), ReplyWait.known_turn?; nil without one
240
240
  # @!attribute running [Boolean] the session's status was running
241
- # @!attribute live [Boolean] running, or a worker or a REPL owns it
241
+ # @!attribute live [Boolean] running, or a worker or a one-shot owns it
242
242
  # @!attribute settled [Boolean] idle, a turn ended and no question
243
243
  # stands (a continue offer waits for its answer)
244
244
  # @!attribute last_turn_id [String, nil] the last ended turn's
@@ -18,7 +18,7 @@ module Samagotchi
18
18
  Usage: chi sessions archive (ID|PREFIX)...
19
19
  Hides each session (and its delegates) from every list and keeps it
20
20
  for good: the retention sweep never deletes it. A live worker is
21
- stopped first; a session running a turn, or open in a chi REPL, is
21
+ stopped first; a session running a turn, or in a one-turn chi run, is
22
22
  refused. chi sessions list --archived shows them; a message you send
23
23
  to one brings it back.
24
24
  TEXT
@@ -76,7 +76,7 @@ module Samagotchi
76
76
  true
77
77
  rescue SessionManager::OwnedByTUI => e
78
78
  where = e.session_id == id ? "it is" : "a delegate of it is"
79
- @stdout.puts("#{short} refused: #{where} open in a chi REPL; close it there first")
79
+ @stdout.puts("#{short} refused: #{where} #{OwnerLock::TUI_BUSY}; let it finish first")
80
80
  false
81
81
  rescue SessionManager::ArchiveRefused => e
82
82
  @stdout.puts("#{short} refused: #{e.message}")
@@ -2,9 +2,9 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "answer_tail"
5
+ require_relative "answer_text"
5
6
  require_relative "config"
6
7
  require_relative "log"
7
- require_relative "output_formatter"
8
8
  require_relative "session"
9
9
  require_relative "session_inbox"
10
10
  require_relative "steer"
@@ -109,10 +109,11 @@ module Samagotchi
109
109
  nil
110
110
  end
111
111
 
112
- # The session's last word: the text of AnswerTail's newest bubble, with
113
- # the wire-format markup stripped, unless that bubble is a step of a
114
- # turn (it asked for tools) or old text a later turn never replaced
115
- # (one that failed or was cut before it answered). A cut reply keeps
112
+ # The session's last word: the text of AnswerTail's newest bubble as the
113
+ # user saw it (AnswerText.visible: no thought, a cut-off one neither),
114
+ # unless that bubble is a step of a turn (it asked for tools), old text a
115
+ # later turn never replaced (one that failed or was cut before it
116
+ # answered) or a thought alone. A cut reply keeps
116
117
  # its "[interrupted]": that is what happened.
117
118
  # @param messages [Array<Hash>]
118
119
  # @return [String, nil]
@@ -123,7 +124,8 @@ module Samagotchi
123
124
  at = messages.rindex { |m| m.equal?(found) }
124
125
  return nil if messages[(at + 1)..].any? { |m| m[:role].to_s == "tool_response" || later_turn?(m) }
125
126
 
126
- OutputFormatter.strip_markup(found[:content].scrub)
127
+ text = AnswerText.visible(found[:content])
128
+ text.empty? ? nil : text
127
129
  end
128
130
 
129
131
  # A turn's opening, not a `!shell` line: a command the user ran after